DEV Community

137Foundry
137Foundry

Posted on

How to Build Type-Safe Form Handlers Using TypeScript Utility Types

Form handling is one of the areas where TypeScript utility types save the most repetition. A typical form involves at least three distinct type states: the initial (possibly empty) form, the in-progress state as the user fills it out, and the validated payload ready for submission. Without utility types, you write three separate interfaces and keep them in sync manually. With utility types, you write one and derive the rest.

Step 1: Define the Submission Schema

Start with the type that represents the complete, valid submission -- every field required, no optionals.

interface ContactFormData {
  name: string;
  email: string;
  company: string;
  message: string;
  acceptedTerms: boolean;
}
Enter fullscreen mode Exit fullscreen mode

This is your canonical type. Everything else derives from it.

Step 2: Derive the Form State Type

Form state is the same shape as the submission type, but every field is optional. A user may not have filled anything in yet, and you do not want TypeScript complaining about empty strings or undefined fields during the editing phase.

type ContactFormState = Partial<ContactFormData>;
Enter fullscreen mode Exit fullscreen mode

That is it. One line. ContactFormState has the same fields as ContactFormData but all optional.

In a React component:

import { useState } from 'react';

function ContactForm() {
  const [formState, setFormState] = useState<ContactFormState>({});

  function handleChange(field: keyof ContactFormData, value: string | boolean) {
    setFormState(prev => ({ ...prev, [field]: value }));
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Using keyof ContactFormData for the field parameter means TypeScript will catch any typo in field names at the call site. If you rename a field, every call to handleChange with the old name becomes a compile error.

Step 3: Define the Validated Type

After validation, you know all fields are present. Required<T> makes this explicit:

type ValidatedContactForm = Required<ContactFormData>;
Enter fullscreen mode Exit fullscreen mode

Since ContactFormData already has all required fields, ValidatedContactForm is structurally identical here. The value is when your base type has optional fields -- Required<T> strips all ? modifiers and gives you a type you can assert after validation.

function validateForm(state: ContactFormState): asserts state is ValidatedContactForm {
  const required: (keyof ContactFormData)[] = [
    'name', 'email', 'company', 'message', 'acceptedTerms'
  ];

  for (const field of required) {
    if (state[field] === undefined || state[field] === '') {
      throw new Error(`${field} is required`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After validateForm(state) runs without throwing, TypeScript knows state is ValidatedContactForm -- all fields are present and non-undefined.

Step 4: Handle Partial Pre-Population

Some forms pre-populate from existing data -- editing a profile, resuming a draft. The source data is usually a different type than the form type.

interface UserProfile {
  id: string;
  displayName: string;
  email: string;
  bio?: string;
  avatarUrl: string;
  createdAt: Date;
}

interface ProfileFormData {
  displayName: string;
  email: string;
  bio: string;
}

// Initialize form from profile data
function initFormFromProfile(profile: UserProfile): Partial<ProfileFormData> {
  return {
    displayName: profile.displayName,
    email: profile.email,
    bio: profile.bio ?? '',
  };
}
Enter fullscreen mode Exit fullscreen mode

The function returns Partial<ProfileFormData> because bio is optional on UserProfile and may be absent. The form starts pre-populated with what is available.

Step 5: Build a Reusable Field Change Handler

With Partial<T> and keyof T, you can build a generic field change handler that works for any form:

type FormUpdater<T> = (field: keyof T, value: T[keyof T]) => void;

function useFormState<T>(initial: Partial<T> = {}) {
  const [state, setState] = useState<Partial<T>>(initial);

  const update: FormUpdater<T> = (field, value) => {
    setState(prev => ({ ...prev, [field]: value }));
  };

  return { state, update };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const { state, update } = useFormState<ContactFormData>();

update('name', 'Jane Doe');
update('email', 'jane@example.com');
// TypeScript enforces field names and value types
Enter fullscreen mode Exit fullscreen mode

The update function is fully typed. If ContactFormData.email is a string, passing a number to update('email', 42) is a type error. If you pass a field name that does not exist on ContactFormData, it is also a type error.

Step 6: Handle Multi-Section Forms

For longer forms, you might split fields across sections while still validating against one schema. Pick<T, K> lets you create section-specific types:

interface CheckoutFormData {
  // Shipping
  shippingName: string;
  shippingAddress: string;
  shippingCity: string;
  shippingPostalCode: string;
  // Payment
  cardNumber: string;
  expiryDate: string;
  cvv: string;
  // Promo
  promoCode?: string;
}

type ShippingSection = Pick<CheckoutFormData, 'shippingName' | 'shippingAddress' | 'shippingCity' | 'shippingPostalCode'>;
type PaymentSection = Pick<CheckoutFormData, 'cardNumber' | 'expiryDate' | 'cvv'>;
Enter fullscreen mode Exit fullscreen mode

Each section component receives its relevant slice. You validate each section independently and only combine them into the full CheckoutFormData at submission.

Step 7: Discriminated Union for Form Step State

For multi-step forms, a discriminated union makes the current step state explicit:

type FormStep =
  | { step: 'shipping'; data: Partial<ShippingSection> }
  | { step: 'payment'; data: Partial<PaymentSection> }
  | { step: 'review'; data: CheckoutFormData };
Enter fullscreen mode Exit fullscreen mode

The final step requires CheckoutFormData (fully populated), while earlier steps accept partial data. TypeScript enforces that you cannot reach the review step without complete data.

Validation Library Integration

This pattern works cleanly with Zod and React Hook Form. Zod schemas can infer TypeScript types directly, so you define validation rules and get the type for free:

import { z } from 'zod';

const contactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  company: z.string().min(1),
  message: z.string().min(10),
  acceptedTerms: z.literal(true),
});

type ContactFormData = z.infer<typeof contactSchema>;
type ContactFormState = Partial<ContactFormData>;
Enter fullscreen mode Exit fullscreen mode

The z.infer<typeof contactSchema> approach means you write the schema once and get both runtime validation and compile-time types from it. Partial<T> still applies to the inferred type for form state.

Tracking Touched Fields

A common UX requirement is showing validation errors only after a field has been interacted with -- not on initial load. You need to track which fields have been touched separately from which fields have values.

type TouchedFields<T> = Partial<Record<keyof T, boolean>>;

function useFormWithTouched<T>(initial: Partial<T> = {}) {
  const [state, setState] = useState<Partial<T>>(initial);
  const [touched, setTouched] = useState<TouchedFields<T>>({});

  const touch = (field: keyof T) => {
    setTouched(prev => ({ ...prev, [field]: true }));
  };

  const update = (field: keyof T, value: T[keyof T]) => {
    setState(prev => ({ ...prev, [field]: value }));
  };

  const isTouched = (field: keyof T): boolean => !!touched[field];

  return { state, touch, update, isTouched };
}
Enter fullscreen mode Exit fullscreen mode

Record<keyof T, boolean> creates an object with the same keys as your form data type, each holding a boolean. Partial<Record<keyof T, boolean>> makes all those keys optional so you can start with an empty object. keyof T constrains the touch and isTouched functions to valid field names -- passing a nonexistent field name is a type error.

This pattern composes with the validation step: once a field is touched and its value is present, you run validation and show errors. The state shape is fully typed throughout.

What This Pattern Prevents

Without utility types, the common failure mode is having a ContactFormData interface and a separate ContactFormState interface that are supposed to match but diverge as fields are added or renamed. Someone adds a required phone field to ContactFormData, does not update ContactFormState, and the form silently allows submission without it.

With Partial<ContactFormData> as the form state type, there is no separate interface to keep in sync. The form state is always derived from the submission type. Adding phone to ContactFormData automatically adds it to the form state.

Further Reading

The TypeScript handbook covers Partial, Required, Pick, and keyof with their full type definitions. React documentation covers useState typing patterns. MDN Web Docs is a useful reference for the underlying JavaScript form event handling. For a reference covering all built-in utility types with production patterns for API layers, form handling, and configuration objects, see this guide to TypeScript utility types at TypeScript Utility Types: Practical Code Snippets and Patterns.

Top comments (0)