Type-Safe Form Validation: End-to-End TypeScript
An end-to-end type-safe form combines three elements: form input types (what fields exist), validation schemas (what constraints apply), and error states (what validation failed). By connecting these three pieces, TypeScript ensures that field names, value types, and error messages stay in sync across the entire form. When you add a field to the form, you add it to the type, the validation schema, and the error handler all at once. This eliminates a common class of bugs where a form input and its validation diverge.
How Do You Build an End-to-End Type-Safe Form?
Start by defining a form data type with all fields and their types. Then create a Zod schema that matches that type. Finally, build the form component using the type for state and the schema for validation. As you submit, validate the form data against the schema and display errors for invalid fields.
import { useState } from 'react';
import { z } from 'zod';
// Step 1: Define the form data type
interface SignupFormData {
email: string;
password: string;
confirmPassword: string;
agreeToTerms: boolean;
}
// Step 2: Create a Zod schema that matches the type
const signupSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine(v => v === true, {
message: 'You must agree to the terms',
}),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // Set which field the error applies to
});
// Extract the inferred type (should match SignupFormData)
type SignupFormDataFromSchema = z.infer<typeof signupSchema>;
// Step 3: Build the form component
export const SignupForm = () => {
const [formData, setFormData] = useState<SignupFormData>({
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false,
});
const [errors, setErrors] = useState<Partial<Record<keyof SignupFormData, string>>>({});
const [submitted, setSubmitted] = useState(false);
const handleChange = (field: keyof SignupFormData, value: SignupFormData[keyof SignupFormData]) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
// Clear error for this field when user starts editing
setErrors(prev => ({
...prev,
[field]: undefined,
}));
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrors({});
// Validate against schema
const result = signupSchema.safeParse(formData);
if (!result.success) {
// Convert Zod errors to field-indexed errors
const fieldErrors: Partial<Record<keyof SignupFormData, string>> = {};
result.error.errors.forEach(err => {
const path = err.path[0] as keyof SignupFormData;
if (path) {
fieldErrors[path] = err.message;
}
});
setErrors(fieldErrors);
return;
}
// Form is valid, send to server
setSubmitted(true);
console.log('Form submitted:', result.data);
// Reset after successful submission
setTimeout(() => {
setFormData({
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false,
});
setSubmitted(false);
}, 2000);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
value={formData.email}
onChange={e => handleChange('email', e.currentTarget.value)}
placeholder="[email protected]"
/>
{errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
</div>
<div>
<label>Password</label>
<input
type="password"
value={formData.password}
onChange={e => handleChange('password', e.currentTarget.value)}
placeholder="At least 8 characters"
/>
{errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
</div>
<div>
<label>Confirm Password</label>
<input
type="password"
value={formData.confirmPassword}
onChange={e => handleChange('confirmPassword', e.currentTarget.value)}
placeholder="Confirm your password"
/>
{errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword}</p>}
</div>
<div>
<label>
<input
type="checkbox"
checked={formData.agreeToTerms}
onChange={e => handleChange('agreeToTerms', e.currentTarget.checked)}
/>
I agree to the terms
</label>
{errors.agreeToTerms && <p style={{ color: 'red' }}>{errors.agreeToTerms}</p>}
</div>
<button type="submit" disabled={submitted}>
{submitted ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
};
This form is fully type-safe. If you add a new field to SignupFormData, TypeScript warns you that it's missing from handleChange calls. If you add a validation rule to the schema, it automatically applies when you submit.
Creating a Reusable Form Hook
For multiple forms with similar structure, extract the logic into a custom hook:
// Generic form hook for any schema
function useFormValidation<T extends Record<string, any>>(
initialData: T,
schema: z.ZodType<T>
) {
const [formData, setFormData] = useState<T>(initialData);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (field: keyof T, value: T[keyof T]) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
// Clear error when user edits
setErrors(prev => ({
...prev,
[field]: undefined,
}));
};
const handleSubmit = async (onSuccess: (data: T) => Promise<void>) => {
return async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
const result = schema.safeParse(formData);
if (!result.success) {
const fieldErrors: Partial<Record<keyof T, string>> = {};
result.error.errors.forEach(err => {
const path = err.path[0] as keyof T;
if (path) {
fieldErrors[path] = err.message;
}
});
setErrors(fieldErrors);
setIsSubmitting(false);
return;
}
try {
await onSuccess(result.data);
} catch (err) {
console.error('Form submission error:', err);
} finally {
setIsSubmitting(false);
}
};
};
return {
formData,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset: () => {
setFormData(initialData);
setErrors({});
},
};
}
// Use the hook with a different form
const loginSchema = z.object({
username: z.string().min(1, 'Username required'),
password: z.string().min(1, 'Password required'),
});
interface LoginFormData {
username: string;
password: string;
}
export const LoginForm = () => {
const { formData, errors, isSubmitting, handleChange, handleSubmit } = useFormValidation<LoginFormData>(
{ username: '', password: '' },
loginSchema
);
return (
<form onSubmit={handleSubmit(async (data) => {
// Call API to authenticate
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Login failed');
})}>
<input
value={formData.username}
onChange={e => handleChange('username', e.currentTarget.value)}
placeholder="Username"
/>
{errors.username && <p style={{ color: 'red' }}>{errors.username}</p>}
<input
type="password"
value={formData.password}
onChange={e => handleChange('password', e.currentTarget.value)}
placeholder="Password"
/>
{errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
);
};
The useFormValidation hook accepts any form type and schema, making it reusable across all forms in your application.
Complex Validation with Async Rules
For validation that requires async operations (like checking if an email is already registered), use Zod's .refine() method with an async validator:
const registerSchema = z.object({
email: z.string().email('Invalid email'),
username: z.string().min(3, 'Username too short'),
}).refine(async (data) => {
// Check if email is already registered
const response = await fetch(`/api/check-email?email=${data.email}`);
const { available } = await response.json();
return available;
}, {
message: 'Email already registered',
path: ['email'],
});
// When submitting, use .parseAsync() instead of .parse()
const result = await registerSchema.parseAsync(formData);
Zod's .parseAsync() method handles async validators correctly, waiting for all async checks to complete before returning.
Key Takeaways
- Define form data as an interface with all fields and their types.
- Create a Zod schema that matches the form data type exactly.
- Use the schema for validation; convert Zod errors to a field-indexed error object.
- Extract form logic into a custom hook for reuse across multiple forms.
- TypeScript ensures field names and types stay consistent across state, handlers, validation, and errors.
Frequently Asked Questions
Should the form type and Zod schema match exactly?
Yes, use z.infer<typeof schema> to extract the type from the schema and verify they match. If they diverge, validation won't match the component's state type.
How do I handle server-side validation errors?
After the form submits successfully on the client, the server may reject it (e.g., email already in use). Catch the server error and manually set errors: setErrors({ email: 'Already registered' }).
Can I validate fields in real time as the user types?
Yes, call schema.parseAsync() or .safeParse() on every onChange event. Display errors immediately, but this may feel intrusive. Consider validating on blur or submit instead.
What if my form has conditional fields?
Use Zod's .optional() and .refine() methods to add conditional validation. For example, if a checkbox is checked, a text field becomes required.
How do I handle file uploads in type-safe forms?
Define the field as File or File[] in the form type. Use z.instanceof(File) in the schema. In the input, access e.currentTarget.files[0] and set it in state.