ESC

Type to search the knowledge base.

Record and Partial patterns for Forms

Type form values, errors, touched flags, and dirty maps with Record, Partial, and keyof so field names stay in sync.

intermediate3 min read
  • typescript
  • record-and

Forms duplicate field names across values, errors, touched flags, and disabled maps. If those lists drift, TypeScript didn’t fail you — you modeled the form as freeform bags. Record, Partial, and keyof keep one field union everywhere.

Docs: Utility Types, keyof.

Single source: values type

type LoginForm = {
  email: string;
  password: string;
  remember: boolean;
};

type FieldName = keyof LoginForm; // 'email' | 'password' | 'remember'

Errors and touched

type FormErrors<T> = Partial<Record<keyof T, string>>;
type Touched<T> = Partial<Record<keyof T, boolean>>;

const errors: FormErrors<LoginForm> = {
  email: 'Enter a valid email',
};

const touched: Touched<LoginForm> = {
  email: true,
};

Partial means not every field has an error — correct for validation UX.

Full boolean maps

When every key must be present (e.g. dirty flags after init):

type DirtyMap<T> = Record<keyof T, boolean>;

const dirty: DirtyMap<LoginForm> = {
  email: false,
  password: false,
  remember: false,
};

Missing keys fail compile — good for resets.

Generic form state

type FormState<T> = {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  submitting: boolean;
};

const initial: FormState<LoginForm> = {
  values: { email: '', password: '', remember: false },
  errors: {},
  touched: {},
  submitting: false,
};

setField helper

function setField<T, K extends keyof T>(
  state: FormState<T>,
  key: K,
  value: T[K],
): FormState<T> {
  return {
    ...state,
    values: { ...state.values, [key]: value },
    touched: { ...state.touched, [key]: true },
  };
}

Indexed access keeps value aligned with the field.

Validating to errors

function validate(values: LoginForm): FormErrors<LoginForm> {
  const errors: FormErrors<LoginForm> = {};
  if (!values.email.includes('@')) errors.email = 'Invalid email';
  if (values.password.length < 8) errors.password = 'Min 8 characters';
  return errors;
}

Partial updates (PATCH bodies)

type User = {
  id: string;
  name: string;
  email: string;
};

type UserPatch = Partial<Omit<User, 'id'>>;

function updateUser(id: string, patch: UserPatch) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(patch),
  });
}

Same Partial idea as optional form fields.

Record for lookup widgets

const fieldLabels: Record<keyof LoginForm, string> = {
  email: 'Email',
  password: 'Password',
  remember: 'Remember me',
};

If you add a form field and forget the label map, compile fails.

React sketch

function Field({
  name,
  label,
  value,
  error,
  onChange,
}: {
  name: FieldName;
  label: string;
  value: string | boolean;
  error?: string;
  onChange: (name: FieldName, value: string | boolean) => void;
}) {
  const id = String(name);
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        value={typeof value === 'string' ? value : undefined}
        checked={typeof value === 'boolean' ? value : undefined}
        onChange={(e) =>
          onChange(
            name,
            e.target.type === 'checkbox' ? e.target.checked : e.target.value,
          )
        }
      />
      {error ? (
        <p id={`${id}-err`} role="alert">
          {error}
        </p>
      ) : null}
    </div>
  );
}

Wire labels carefully for a11y — accessible forms errors.

Footguns

  1. Record<string, string> for errors — allows any key, loses field sync.
  2. Optional values type vs empty string — prefer always-defined controlled values.
  3. Boolean fields in the same Record as strings — unions get messy; split components by type.
  4. Deep forms — Partial is shallow; nest explicitly.

Interview out-loud answer

“I define form values once, then Partial<Record<keyof T, string>> for errors and touched. Full Record<keyof T, boolean> when every flag must exist. Field helpers use K extends keyof T so setValue stays typed.”

Further reading

Related guides