| name | type-safe-form-validation |
| description | A comprehensive pattern for building type-safe forms and API validation using Zod, with automatic error formatting, runtime type checking, and seamless TypeScript integration. Use when building forms with client-side and server-side validation, validating API request/response payloads, creating reusable validation schemas, or ensuring data integrity across client and server. |
Type-Safe Form Validation with Zod
A comprehensive pattern for building type-safe forms and API validation using Zod, with automatic error formatting, runtime type checking, and seamless TypeScript integration.
When to use this skill
- Building forms with client-side and server-side validation
- Validating API request/response payloads
- Creating reusable validation schemas
- Need automatic TypeScript type inference from schemas
- Want formatted, user-friendly error messages
- Validating complex nested objects and arrays
- Ensuring data integrity across client and server
Core Features
- Single Source of Truth - Define validation schema once, use everywhere
- Type Inference - Automatic TypeScript types from schemas
- Runtime Validation - Catch invalid data at runtime
- User-Friendly Errors - Format errors for display
- Composable Schemas - Build complex validations from simple pieces
- API Integration - Validate requests and responses
Implementation
Step 1: Install Zod
npm install zod
pnpm add zod
Step 2: Create Validation Schemas
Create lib/validation.ts:
import { z } from 'zod';
export function formatValidationError(error: z.ZodError): string {
return error.errors
.map(err => `${err.path.join('.')}: ${err.message}`)
.join(', ');
}
const timestampPattern = /^(?:\d{1,2}:)?\d{1,2}:\d{1,2}$/;
const youtubeIdPattern = /^[a-zA-Z0-9_-]{11}$/;
export const emailSchema = z.string().email('Invalid email address');
export const urlSchema = z.string().url('Invalid URL');
export const timestampSchema = z.string().regex(
timestampPattern,
'Timestamp must be in format HH:MM:SS or MM:SS'
);
export const youtubeUrlSchema = z.string().refine(
(url) => {
try {
const parsed = new URL(url);
return parsed.hostname.includes('youtube.com') || parsed.hostname.includes('youtu.be');
} catch {
return false;
}
},
{ message: 'Must be a valid YouTube URL' }
);
export const videoIdSchema = z.string()
.regex(youtubeIdPattern, 'Invalid YouTube video ID')
.length(11, 'YouTube video ID must be 11 characters');
export const transcriptSegmentSchema = z.object({
text: z.string().min(1),
start: z.number().nonnegative(),
duration: z.number().positive()
});
export const generateTopicsRequestSchema = z.object({
transcript: z.array(transcriptSegmentSchema).min(1, 'Transcript cannot be empty'),
model: z.enum(['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-pro']).optional(),
includeCandidatePool: z.boolean().optional(),
excludeTopicKeys: z.array(z.string()).optional(),
videoInfo: z.object({
title: z.string(),
author: z.string(),
duration: z.number().nullable()
}).optional(),
mode: z.enum(['smart', 'fast']).optional()
});
export const chatRequestSchema = z.object({
message: z.string().min(1, 'Message cannot be empty').max(1000, 'Message too long'),
transcript: z.array(transcriptSegmentSchema),
conversationHistory: z.array(z.object({
role: z.enum(['user', 'assistant']),
content: z.string()
})).optional()
});
export const createNoteSchema = z.object({
videoId: z.string().uuid('Invalid video ID'),
source: z.enum(['chat', 'takeaways', 'transcript', 'custom']),
text: z.string().min(1, 'Note cannot be empty').max(5000, 'Note too long'),
metadata: z.object({
transcript: z.object({
start: z.number(),
end: z.number().optional(),
segmentIndex: z.number().optional()
}).optional(),
selectedText: z.string().optional()
}).optional()
});
export const registerSchema = z.object({
email: emailSchema,
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"]
}
);
export type GenerateTopicsRequest = z.infer<typeof generateTopicsRequestSchema>;
export type ChatRequest = z.infer<typeof chatRequestSchema>;
export type CreateNote = z.infer<typeof createNoteSchema>;
export type RegisterForm = z.infer<typeof registerSchema>;
Step 3: Create Error Formatter
import { z } from 'zod';
export interface ValidationError {
field: string;
message: string;
}
export function formatZodErrors(error: z.ZodError): ValidationError[] {
return error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
}
export function getFieldError(
errors: ValidationError[],
field: string
): string | undefined {
return errors.find(e => e.field === field)?.message;
}
Usage Examples
Example 1: API Route Validation
import { NextRequest, NextResponse } from 'next/server';
import { generateTopicsRequestSchema, formatValidationError } from '@/lib/validation';
import { z } from 'zod';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
let validatedData;
try {
validatedData = generateTopicsRequestSchema.parse(body);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: formatValidationError(error)
},
{ status: 400 }
);
}
throw error;
}
const { transcript, model, mode } = validatedData;
const topics = await generateTopics(transcript, model);
.({ topics });
} (error) {
.(
{ : },
{ : }
);
}
}
Benefits:
- ✅ Type-safe request data
- ✅ User-friendly error messages
- ✅ 400 for validation errors, 500 for server errors
- ✅ No manual type checking
Example 2: React Hook Form Integration
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, RegisterForm } from '@/lib/validation';
export function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema)
});
const onSubmit = async (data: RegisterForm) => {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
< {('')} = = />
{errors.email && {errors.email.message}}
{errors.password && {errors.password.message}}
{errors.confirmPassword && (
{errors.confirmPassword.message}
)}
Sign Up
);
}
Benefits:
- ✅ Automatic client-side validation
- ✅ Type-safe form data
- ✅ No manual error checking
- ✅ Disabled submit during processing
Example 3: Nested Object Validation
import { z } from 'zod';
const projectSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
settings: z.object({
visibility: z.enum(['public', 'private', 'unlisted']),
features: z.object({
comments: z.boolean(),
analytics: z.boolean(),
aiGeneration: z.object({
enabled: z.boolean(),
model: z.enum(['fast', 'balanced', 'quality']),
maxTokens: z.number().min(100).max(10000)
})
}),
limits: z.object({
maxVideos: z.number().positive(),
maxNotes: z.number().positive()
})
}),
tags: z.array(z.()).().()
});
= z.< projectSchema>;
: = ();
validated = projectSchema.(project);
.(validated....);
Example 4: Array Validation with Constraints
import { z } from 'zod';
const topicsSchema = z.array(
z.object({
id: z.string().uuid(),
title: z.string().min(3).max(100),
segments: z.array(
z.object({
start: z.number().nonnegative(),
end: z.number().positive()
})
).min(1, 'Each topic must have at least one segment')
})
).min(3, 'Must have at least 3 topics')
.max(10, 'Cannot have more than 10 topics');
try {
const topics = topicsSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
console.error(formatValidationError(error));
}
}
Example 5: Transform and Validate
import { z } from 'zod';
const urlInputSchema = z.object({
url: z.string()
.transform((url) => url.trim())
.pipe(
z.string().url('Invalid URL')
)
.transform((url) => {
const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
})
.pipe(
z.string().nullable().refine(
(id) => id !== null,
{ message: 'Could not extract video ID from URL' }
)
)
});
const result = urlInputSchema.parse({
url: ' https://youtube.com/watch?v=dQw4w9WgXcQ '
});
console.log(result.url);
Example 6: Conditional Validation
import { z } from 'zod';
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('credit_card'),
cardNumber: z.string().length(16),
cvv: z.string().length(3),
expiry: z.string().regex(/^\d{2}\/\d{2}$/)
}),
z.object({
method: z.literal('paypal'),
email: z.string().email()
}),
z.object({
method: z.literal('bank_transfer'),
accountNumber: z.string().min(8),
routingNumber: z.string().length(9)
})
]);
type Payment = z.infer<typeof paymentSchema>;
function processPayment(: ) {
(payment.) {
:
(payment., payment.);
:
(payment.);
:
(payment.);
}
}
Advanced Patterns
Pattern 1: Custom Validators
import { z } from 'zod';
const strongPasswordSchema = z.string().superRefine((password, ctx) => {
if (password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must be at least 8 characters'
});
}
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain uppercase letter'
});
}
if (!/[a-z]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain lowercase letter'
});
}
if (!/[0-9]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain a number'
});
}
if (!/[!@#$%^&*]/.test(password)) {
ctx.addIssue({
: z..,
:
});
}
});
Pattern 2: Schema Composition
import { z } from 'zod';
const baseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime()
});
const userDataSchema = z.object({
email: z.string().email(),
name: z.string().min(1)
});
const userSchema = baseEntitySchema.merge(userDataSchema);
type User = z.infer<typeof userSchema>;
Pattern 3: Partial Updates
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().positive(),
bio: z.string().optional()
});
const updateUserSchema = userSchema.partial();
type UpdateUser = z.infer<typeof updateUserSchema>;
const updateUserWithOneField = userSchema.partial().refine(
(data) => Object.keys(data).length > 0,
{ message: 'At least one field must be provided' }
);
Pattern 4: Default Values
import { z } from 'zod';
const configSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
language: z.string().default('en'),
notifications: z.object({
email: z.boolean().default(true),
push: z.boolean().default(false),
sms: z.boolean().default(false)
}).default({})
});
const config = configSchema.parse({});
Form Component Patterns
Pattern 1: Reusable Form Field
import { UseFormRegister, FieldError } from 'react-hook-form';
interface FormFieldProps {
name: string;
label: string;
register: UseFormRegister<any>;
error?: FieldError;
type?: string;
required?: boolean;
}
export function FormField({ name, label, register, error, type = 'text', required }: FormFieldProps) {
return (
<div className="form-field">
<label htmlFor={name}>
{label}
{required && <span className="required">*</span>}
</label>
<input
id={name}
type={type}
{...register(name)}
aria-invalid={error ? 'true' ''}
= ? '' ''}
/>
{error && (
{error.message}
)}
);
}
Pattern 2: Multi-Step Form Validation
import { z } from 'zod';
const step1Schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const step2Schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1)
});
const step3Schema = z.object({
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' })
})
});
function MultiStepForm() {
const [step, setStep] = useState(1);
const currentSchema = {
1: step1Schema,
2: step2Schema,
3: step3Schema
}[step];
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(currentSchema)
});
const onSubmit = (data) => {
(step < ) {
(step + );
} {
fullSchema = step1Schema.(step2Schema).(step3Schema);
validated = fullSchema.(allData);
(validated);
}
};
;
}
Best Practices
- Define schemas in lib/validation.ts - Central location
- Export inferred types -
export type X = z.infer<typeof xSchema>
- Validate at API boundaries - Both client and server
- Use formatValidationError - Consistent error formatting
- Compose schemas - Build complex from simple
- Set meaningful error messages - Help users fix issues
- Use discriminated unions - For conditional validation
- Add defaults where appropriate - Better UX
Common Pitfalls
- Forgetting to call .parse() - Schema alone doesn't validate
- Not handling ZodError - Always catch and format
- Circular dependencies - Use z.lazy() for recursive schemas
- Overly strict validation - Balance security and UX
- Client-only validation - Always validate on server too
- Not using type inference - Manually typing defeats the purpose
- Complex regex without explanation - Add comments
Testing
import { describe, test, expect } from 'vitest';
import { registerSchema } from '@/lib/validation';
import { z } from 'zod';
describe('registerSchema', () => {
test('valid registration data passes', () => {
const valid = {
email: 'user@example.com',
password: 'SecurePass1',
confirmPassword: 'SecurePass1'
};
expect(() => registerSchema.parse(valid)).not.toThrow();
});
test('invalid email fails', () => {
const invalid = {
email: 'not-an-email',
password: 'SecurePass1',
confirmPassword: 'SecurePass1'
};
expect(() => registerSchema.parse(invalid)).toThrow(z.ZodError);
});
test('mismatched passwords fail', () => {
const invalid = {
email: 'user@example.com',
password: ,
:
};
( registerSchema.(invalid)).(z.);
});
(, {
invalid = {
: ,
: ,
:
};
( registerSchema.(invalid)).(z.);
});
});
Migration from Manual Validation
Before:
function validateUser(data: any) {
const errors: string[] = [];
if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) {
errors.push('Invalid email');
}
if (!data.password || data.password.length < 8) {
errors.push('Password too short');
}
if (data.password !== data.confirmPassword) {
errors.push('Passwords do not match');
}
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
return data as User;
}
After:
import { registerSchema } from '@/lib/validation';
function validateUser(data: unknown) {
return registerSchema.parse(data);
}
Next Steps
After implementing this skill:
- Replace all manual validation with Zod schemas
- Add schemas for all API routes
- Integrate with React Hook Form for client forms
- Create reusable validation patterns library
- Add custom validators for business logic
- Set up schema testing
Related Skills
- Secure Next.js API Routes - Validate requests before processing
- AI Model Cascade - Validate AI responses with schemas
- Resilient Async Operations - Combine with safePromise for parsing
Built from production validation patterns in TLDW