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;
}
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>;
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 }));
}
// ...
}
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>;
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`);
}
}
}
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 ?? '',
};
}
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 };
}
Usage:
const { state, update } = useFormState<ContactFormData>();
update('name', 'Jane Doe');
update('email', 'jane@example.com');
// TypeScript enforces field names and value types
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'>;
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 };
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>;
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 };
}
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)