Build Type-Safe Form Validators in TypeScript
Most form validation bugs trace back to runtime type mismatches. Learn how to build composable, type-safe validators that catch errors at compile time and scale across complex applications.
Why Type-Safe Validators Matter in Modern Applications
Most form validation failures in production stem from a disconnect between what developers assume about data structure and what actually arrives at runtime. Teams write validators that accept any, return boolean values without context, and chain logic that TypeScript cannot verify. The result: runtime errors that type checking should have prevented.
Type-safe validators solve this by making validation rules part of your type system. When a validator runs, TypeScript knows exactly what shape the data takes if validation succeeds. This matters because form validation sits at the boundary between user input and application logic—the exact place where type safety delivers maximum value.
The distinction is critical. A validator that returns boolean tells you nothing about the validated data's shape. A validator that narrows types through predicates gives you compile-time guarantees about what operations are safe downstream. This difference eliminates entire categories of bugs before code reaches production.
Building a Generic Validator Type System
The foundation of type-safe validation is a result type that captures both success and failure states with their associated data. Developers need a structure that TypeScript can narrow discriminately.
type ValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: ValidationError[] };
interface ValidationError {
field: string;
message: string;
code: string;
}
type Validator<TInput, TOutput = TInput> = (
value: TInput
) => ValidationResult<TOutput>;This type system makes validation results explicit. When success is true, TypeScript knows data exists with type TOutput. When success is false, TypeScript knows errors exists. The generic parameters allow validators to transform input types—a string validator can output a branded type or a more specific union.
The validator function signature separates input and output types deliberately. Most validators preserve the input type, but transforming validators (parsing numbers from strings, normalizing data) need different input and output types. This flexibility matters in real applications where validation often includes coercion.
%% alt: Type-safe validator architecture with discriminated union results
flowchart TD
A[Input: unknown] --> B{Validator Function}
B --> C[ValidationResult]
C --> D{success: true}
C --> E{success: false}
D --> F[data: TOutput]
E --> G[errors: ValidationError[]]
F --> H[Type Narrowed<br/>Safe Operations]
G --> I[Error Handling<br/>User Feedback]
The diagram shows how validators narrow types through discriminated unions. Once code checks the success property, TypeScript automatically narrows to the correct branch. This pattern eliminates the need for type assertions and makes error handling explicit.

Creating Reusable Field Validators with Type Guards
Field validators need to be composable and reusable across forms. The pattern that scales best combines type predicates with the result type system.
function createStringValidator(
minLength?: number,
maxLength?: number
): Validator<unknown, string> {
return (value: unknown): ValidationResult<string> => {
if (typeof value !== 'string') {
return {
success: false,
errors: [{
field: 'value',
message: 'Must be a string',
code: 'INVALID_TYPE'
}]
};
}
const errors: ValidationError[] = [];
if (minLength !== undefined && value.length < minLength) {
errors.push({
field: 'value',
message: `Must be at least ${minLength} characters`,
code: 'MIN_LENGTH'
});
}
if (maxLength !== undefined && value.length > maxLength) {
errors.push({
field: 'value',
message: `Must be no more than ${maxLength} characters`,
code: 'MAX_LENGTH'
});
}
if (errors.length > 0) {
return { success: false, errors };
}
return { success: true, data: value };
};
}
// Email validator that outputs a branded type
type Email = string & { readonly __brand: 'Email' };
function emailValidator(value: unknown): ValidationResult<Email> {
const stringResult = createStringValidator()(value);
if (!stringResult.success) {
return stringResult;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(stringResult.data)) {
return {
success: false,
errors: [{
field: 'value',
message: 'Must be a valid email address',
code: 'INVALID_EMAIL'
}]
};
}
return { success: true, data: stringResult.data as Email };
}This approach creates validators that TypeScript can track through the type system. The createStringValidator factory takes unknown input and outputs a validated string. The email validator builds on this foundation and narrows further to a branded type. Branded types prevent accidentally passing regular strings where emails are required.
The implication here is that type safety compounds as validators compose. Each layer adds more specific guarantees without losing the validation context from previous layers.
Composing Validators: Chain and Combine Patterns
Complex forms require combining multiple validation rules. The pattern that maintains type safety is explicit composition through combinators.
function chain<T, U, V>(
first: Validator<T, U>,
second: Validator<U, V>
): Validator<T, V> {
return (value: T): ValidationResult<V> => {
const firstResult = first(value);
if (!firstResult.success) {
return firstResult;
}
return second(firstResult.data);
};
}
function combine<T>(
...validators: Validator<T, T>[]
): Validator<T, T> {
return (value: T): ValidationResult<T> => {
const allErrors: ValidationError[] = [];
for (const validator of validators) {
const result = validator(value);
if (!result.success) {
allErrors.push(...result.errors);
}
}
if (allErrors.length > 0) {
return { success: false, errors: allErrors };
}
return { success: true, data: value };
};
}
// Usage: chain transforms types sequentially
const parseAndValidateAge = chain(
createStringValidator(),
(str): ValidationResult<number> => {
const num = parseInt(str, 10);
if (isNaN(num)) {
return {
success: false,
errors: [{
field: 'age',
message: 'Must be a valid number',
code: 'INVALID_NUMBER'
}]
};
}
if (num < 0 || num > 120) {
return {
success: false,
errors: [{
field: 'age',
message: 'Must be between 0 and 120',
code: 'OUT_OF_RANGE'
}]
};
}
return { success: true, data: num };
}
);The chain combinator passes successful results through a pipeline, transforming types at each step. The combine combinator runs all validators and collects errors, useful when multiple independent rules apply to the same field. TypeScript tracks the type transformations through the entire chain.
Type-Safe Error Handling and Messages
Validation errors need structure that supports internationalization and detailed feedback. The error type system should make it impossible to create malformed error messages.
const ErrorCodes = {
REQUIRED: 'REQUIRED',
INVALID_TYPE: 'INVALID_TYPE',
MIN_LENGTH: 'MIN_LENGTH',
MAX_LENGTH: 'MAX_LENGTH',
INVALID_EMAIL: 'INVALID_EMAIL',
OUT_OF_RANGE: 'OUT_OF_RANGE'
} as const;
type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
interface TypedValidationError {
field: string;
code: ErrorCode;
params?: Record<string, unknown>;
}
type ErrorMessages = Record<ErrorCode, (params?: Record<string, unknown>) => string>;
const defaultMessages: ErrorMessages = {
REQUIRED: () => 'This field is required',
INVALID_TYPE: (params) => `Expected ${params?.expected || 'valid type'}`,
MIN_LENGTH: (params) => `Must be at least ${params?.min} characters`,
MAX_LENGTH: (params) => `Must be no more than ${params?.max} characters`,
INVALID_EMAIL: () => 'Must be a valid email address',
OUT_OF_RANGE: (params) => `Must be between ${params?.min} and ${params?.max}`
};
function formatError(error: TypedValidationError, messages = defaultMessages): string {
const formatter = messages[error.code];
return formatter(error.params);
}This structure separates error codes from messages, making internationalization straightforward. The params object allows dynamic values in error messages while maintaining type safety. Teams can replace defaultMessages with localized versions without changing validation logic.

Custom vs Library Validators: When to Build Your Own
The decision between custom validators and libraries like Zod or Yup depends on specific application needs and team constraints.
%% alt: Comparison of custom validators vs library-based validation approaches
flowchart LR
subgraph CustomValidators["Custom Validators"]
A1[Full Type Control] --> A2[Zero Dependencies]
A2 --> A3[Explicit Logic]
A3 --> A4[Bundle Size: Minimal]
end
subgraph LibraryValidators["Library Validators"]
B1[Rich Feature Set] --> B2[Community Support]
B2 --> B3[Tested Patterns]
B3 --> B4[Bundle Size: 10-50KB]
end
A4 --> C{Decision Point}
B4 --> C
C --> D[Use Custom:<br/>Specific needs,<br/>performance critical]
C --> E[Use Library:<br/>Complex schemas,<br/>rapid development]
Custom validators make sense when applications need precise control over validation logic, minimal bundle size matters, or validation rules are domain-specific and stable. The patterns shown earlier provide full type safety with zero runtime dependencies. Teams that understand their validation requirements and rarely change them benefit from this approach.
Library validators excel when schemas change frequently, applications need complex nested validation, or teams want battle-tested patterns. Libraries handle edge cases that custom code might miss. The tradeoff is bundle size and learning curve. In other words, libraries pay upfront cost for long-term flexibility.
The failure mode here is adopting a library for simple validation that custom code handles better, or building custom validators for complex scenarios where libraries prevent bugs. Match the tool to the complexity.
Advanced Patterns: Async Validators and Cross-Field Validation
Production applications often need validators that check against external systems or compare multiple fields. These patterns extend the base validator type system.
type AsyncValidator<TInput, TOutput = TInput> = (
value: TInput
) => Promise<ValidationResult<TOutput>>;
function createAsyncEmailValidator(
checkAvailability: (email: string) => Promise<boolean>
): AsyncValidator<unknown, Email> {
return async (value: unknown): Promise<ValidationResult<Email>> => {
const emailResult = emailValidator(value);
if (!emailResult.success) {
return emailResult;
}
const isAvailable = await checkAvailability(emailResult.data);
if (!isAvailable) {
return {
success: false,
errors: [{
field: 'email',
message: 'Email address is already registered',
code: 'EMAIL_TAKEN'
}]
};
}
return emailResult;
};
}
// Cross-field validation for password confirmation
interface PasswordFields {
password: string;
confirmPassword: string;
}
function passwordMatchValidator(
fields: PasswordFields
): ValidationResult<PasswordFields> {
if (fields.password !== fields.confirmPassword) {
return {
success: false,
errors: [{
field: 'confirmPassword',
message: 'Passwords must match',
code: 'PASSWORD_MISMATCH'
}]
};
}
return { success: true, data: fields };
}Async validators maintain the same type safety as synchronous ones. The key difference is returning Promise<ValidationResult<T>> instead of ValidationResult<T>. TypeScript tracks the async nature through the entire validation chain.
Cross-field validators take multiple fields as input and validate relationships between them. This pattern keeps validation logic centralized rather than scattered across form components. The type system ensures all required fields exist before validation runs.
Integration and Real-World Implementation
Real applications need validators integrated into form state management. The pattern that maintains type safety throughout is a typed form schema.
interface RegistrationForm {
username: string;
email: Email;
age: number;
password: string;
confirmPassword: string;
}
type FormValidators<T> = {
[K in keyof T]?: Validator<unknown, T[K]>;
};
const registrationValidators: FormValidators<RegistrationForm> = {
username: chain(
createStringValidator(3, 20),
(str): ValidationResult<string> => {
if (!/^[a-zA-Z0-9_]+$/.test(str)) {
return {
success: false,
errors: [{
field: 'username',
message: 'Only letters, numbers, and underscores allowed',
code: 'INVALID_FORMAT'
}]
};
}
return { success: true, data: str };
}
),
email: emailValidator,
age: parseAndValidateAge
};
function validateForm<T extends Record<string, unknown>>(
data: Record<string, unknown>,
validators: FormValidators<T>
): ValidationResult<T> {
const errors: ValidationError[] = [];
const validated: Partial<T> = {};
for (const [field, validator] of Object.entries(validators)) {
if (!validator) continue;
const result = validator(data[field]);
if (!result.success) {
errors.push(...result.errors.map(err => ({
...err,
field
})));
} else {
validated[field as keyof T] = result.data;
}
}
if (errors.length > 0) {
return { success: false, errors };
}
return { success: true, data: validated as T };
}This integration pattern validates entire forms while maintaining field-level type safety. The FormValidators type maps each form field to its validator, ensuring validators output the correct types for each field. TypeScript verifies that all required fields have validators and that validator output types match form field types.
The validateForm function processes all fields, collects errors, and returns a fully typed result. When validation succeeds, teams know the data matches the form schema exactly. This eliminates the type assertions and runtime checks that plague untyped validation.
That covers the essential patterns for building type-safe form validators in TypeScript. Apply these in production and the difference will be immediate—fewer runtime errors, better developer experience, and validation logic that scales with application complexity.