Schema-Based Data Validation with Zod in TypeScript
Validate and sanitize incoming data using Zod schemas with TypeScript inference, custom refinements, and error formatting for reliable API and form validation
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Schema-Based Data Validation with Zod in TypeScript
Use Zod to define schemas that validate runtime data while automatically inferring TypeScript types. The solution below covers basic schemas, custom refinements, error formatting, and integration with forms and APIs for bulletproof data validation.
When to Use This
- API request bodies must be validated before processing
- Form inputs need both client-side and server-side validation
- Configuration objects and environment variables require type-safe parsing. See Parse JSON for parsing structured config data.
Solution
1. Basic Schema Definition
// schemas/User.ts
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().min(0).max(150),
role: z.enum(['user', 'admin', 'moderator']),
isActive: z.boolean().default(true),
createdAt: z.coerce.date(),
});
// Infer TypeScript type automatically
type User = z.infer<typeof UserSchema>;
2. Custom Refinements
// schemas/Password.ts
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine(
(val) => /[A-Z]/.test(val),
'Password must contain an uppercase letter'
)
.refine(
(val) => /[0-9]/.test(val),
'Password must contain a number'
)
.refine(
(val) => /[^a-zA-Z0-9]/.test(val),
'Password must contain a special character'
);
// Reusable email with domain check
const WorkEmailSchema = z.string()
.email()
.refine(
(val) => val.endsWith('@company.com'),
'Email must be a company address'
);
3. Nested and Array Schemas
// schemas/Order.ts
const OrderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive().multipleOf(0.01),
});
const OrderSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(OrderItemSchema).min(1),
shippingAddress: z.object({
street: z.string().min(5),
city: z.string().min(2),
zipCode: z.string().regex(/^\d{5}$/),
}),
total: z.number().positive(),
});
4. Error Formatting
// utils/parseErrors.ts
function formatZodErrors(error: z.ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
error.issues.forEach((issue) => {
const path = issue.path.join('.');
formatted[path] = issue.message;
});
return formatted;
}
// API middleware
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: formatZodErrors(result.error),
});
}
req.validatedBody = result.data;
next();
};
}
// Usage
app.post('/users', validateBody(UserSchema), (req, res) => {
const user = req.validatedBody;
// user is fully typed and validated
});
5. Form Integration
// hooks/useZodForm.ts
import { useState } from 'react';
function useZodForm<T extends z.ZodObject<any>>(schema: T) {
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (data: unknown): z.infer<T> | null => {
const result = schema.safeParse(data);
if (!result.success) {
setErrors(formatZodErrors(result.error));
return null;
}
setErrors({});
return result.data;
};
return { validate, errors };
}
How It Works
- Zod schemas define shape, type, and constraints declaratively
- Type inference generates TypeScript types from schemas automatically
- Refinements add custom validation logic beyond built-in checks
- Safe parse returns discriminated unions for explicit error handling
- Coercion transforms string inputs into proper types (dates, numbers)
Production Considerations
- Use
.strict()to reject unexpected properties and prevent injection - Precompile schemas for hot paths to reduce parsing overhead
- Combine Zod with tRPC for end-to-end type-safe APIs. See REST API design.
Common Mistakes
- Using
.parse()without try-catch, crashing on invalid input - Not coercing query parameters and form data, which arrive as strings. See input validation.
- Creating new schema instances on every render instead of reusing them
FAQ
Q: How does Zod compare to Yup or Joi? A: Zod offers native TypeScript inference without a separate type declaration. It has zero dependencies and tree-shakes well.
Q: Can Zod validate async operations?
A: Yes. Use .refine() with an async function for database uniqueness checks or external validation.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Advanced Solutions
Environment variable validation
Validate environment variables at startup so missing or invalid config fails fast instead of causing runtime errors:
// schemas/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().regex(/^\d+[smhd]$/, 'Must be like "1h" or "7d"'),
CORS_ORIGINS: z.string()
.transform((val) => val.split(','))
.pipe(z.array(z.string().url())),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
parsed.error.issues.forEach((issue) => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1);
}
export const env = parsed.data;
// env is fully typed: env.PORT is number, env.CORS_ORIGINS is string[]
Discriminated unions for API responses
Handle polymorphic payloads with discriminated unions so each variant gets its own typed shape:
// schemas/webhook.ts
import { z } from 'zod';
const OrderCreatedEvent = z.object({
type: z.literal('order.created'),
data: z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
amount: z.number().positive(),
currency: z.string().length(3),
}),
});
const OrderRefundedEvent = z.object({
type: z.literal('order.refunded'),
data: z.object({
orderId: z.string().uuid(),
refundAmount: z.number().positive(),
reason: z.string().min(1).max(500),
}),
});
const OrderShippedEvent = z.object({
type: z.literal('order.shipped'),
data: z.object({
orderId: z.string().uuid(),
carrier: z.string(),
trackingNumber: z.string(),
shippedAt: z.coerce.date(),
}),
});
const WebhookEvent = z.discriminatedUnion('type', [
OrderCreatedEvent,
OrderRefundedEvent,
OrderShippedEvent,
]);
type WebhookEvent = z.infer<typeof WebhookEvent>;
// Usage: TypeScript narrows the type based on `type`
function handleWebhook(event: WebhookEvent) {
switch (event.type) {
case 'order.created':
// event.data.amount is number
console.log(`Order ${event.data.orderId} created for ${event.data.amount}`);
break;
case 'order.refunded':
// event.data.refundAmount is number
console.log(`Refund ${event.data.refundAmount} for ${event.data.orderId}`);
break;
case 'order.shipped':
// event.data.trackingNumber is string
console.log(`Shipped via ${event.data.carrier}: ${event.data.trackingNumber}`);
break;
}
}
Async refinements for database checks
// schemas/registration.ts
import { z } from 'zod';
import { db } from '../db/client';
const RegistrationSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8),
})
.refine(
async (data) => {
const existing = await db.user.findUnique({
where: { email: data.email },
});
return !existing;
},
{ message: 'Email already registered', path: ['email'] },
)
.refine(
async (data) => {
const existing = await db.user.findUnique({
where: { username: data.username },
});
return !existing;
},
{ message: 'Username already taken', path: ['username'] },
);
// Usage with async parse
async function registerUser(input: unknown) {
const result = await RegistrationSchema.safeParseAsync(input);
if (!result.success) {
return { errors: formatZodErrors(result.error) };
}
// result.data is fully validated including async checks
return { user: result.data };
}
Recursive schemas for nested comments
// schemas/comment.ts
import { z } from 'zod';
const CommentSchema: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.string().uuid(),
author: z.string().min(1),
body: z.string().min(1).max(5000),
createdAt: z.coerce.date(),
replies: z.array(CommentSchema).default([]),
}),
);
interface Comment {
id: string;
author: string;
body: string;
createdAt: Date;
replies: Comment[];
}
// Validates arbitrarily nested comment trees
const commentTree = CommentSchema.parse(inputFromApi);
Zod with React Hook Form integration
// hooks/useZodForm.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const RegistrationFormSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] },
);
type RegistrationForm = z.infer<typeof RegistrationFormSchema>;
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationForm>({
resolver: zodResolver(RegistrationFormSchema),
});
const onSubmit = async (data: RegistrationForm) => {
// data is fully typed and validated
await api.register(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} />
{errors.firstName && <span>{errors.firstName.message}</span>}
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Register</button>
</form>
);
}
Additional Best Practices
- Use
.strict()on API input schemas. Reject unexpected properties to prevent mass assignment attacks and catch client bugs early:
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
role: z.enum(['user', 'admin']).default('user'),
}).strict(); // Rejects unknown keys like `id`, `createdAt`, `isAdmin`
// Without .strict(), an attacker could send { email, name, role: 'admin', id: '...' }
// and the extra fields would pass through silently
- Reuse schemas with
.pick(),.omit(), and.extend(). Avoid duplicating schema definitions for related DTOs:
const BaseUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
role: z.enum(['user', 'admin']),
});
// CreateUser: same fields, no id
const CreateUserSchema = BaseUserSchema.strict();
// UpdateUser: all fields optional
const UpdateUserSchema = BaseUserSchema.partial();
// AdminUser: extends with admin-only fields
const AdminUserSchema = BaseUserSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
});
Additional Common Mistakes
- Not stripping sensitive fields from output. Validation schemas define what input you accept, but you also need output schemas to avoid leaking fields like
passwordHash:
// Input schema accepts password
const RegisterInputSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// Output schema excludes password
const UserOutputSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.coerce.date(),
}).strict();
// Always validate output before sending to client
app.post('/register', async (req, res) => {
const input = RegisterInputSchema.parse(req.body);
const user = await createUser(input);
const output = UserOutputSchema.parse(user); // Strips passwordHash
res.json(output);
});
- Using
.regex()for email validation instead of.email(). Custom regex patterns often miss edge cases defined in RFC 5322. Use Zod’s built-in.email()which handles common formats correctly, and add domain restrictions with.refine()only if needed.
Additional FAQ
How do I handle file uploads with Zod?
Zod does not validate file contents directly. Validate the metadata (filename, MIME type, size) with Zod, then validate the file contents separately:
const FileUploadSchema = z.object({
filename: z.string().min(1).max(255),
mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
size: z.number().int().positive().max(10 * 1024 * 1024, 'Max 10MB'),
});
// Validate metadata, then check magic bytes separately
const fileMeta = FileUploadSchema.parse({
filename: file.name,
mimeType: file.type,
size: file.size,
});
Can I use Zod with MongoDB or Mongoose?
Yes. Define Zod schemas for input validation and use Mongoose schemas for storage. Validate incoming data with Zod before passing to Mongoose models. This separates validation concerns (Zod) from persistence concerns (Mongoose):
const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.number().int().min(0).max(150).optional(),
});
// Validate input, then save with Mongoose
const validated = CreateUserInput.parse(req.body);
const user = await User.create(validated);
How does Zod handle prototype pollution?
Zod strips __proto__, constructor, and prototype keys from parsed objects by default when using .strict() or when the schema does not explicitly allow those keys. Always use .strict() on API input schemas to reject any unexpected keys including prototype pollution attempts.
Related Resources
Go REST API with Gin and Middleware
Build production-ready REST APIs in Go using the Gin framework with custom middleware for logging, authentication, validation, and error handling
PatternBuilder Pattern
Construct complex objects step by step. A creational design pattern for readable, configurable object construction.
GuideSecurity Best Practices Guide
A thorough guide to application security: authentication, authorization, input validation, secrets management, and common vulnerability prevention.
RecipeExpress.js Middleware Composition Patterns
Build maintainable Express applications using middleware composition patterns for authentication, validation, error handling, request context propagation, and async route wrappers
RecipeValidate and Sanitize GraphQL Input Types Server-Side
Implement centralized input validation in GraphQL using custom validation functions, Zod schemas, and input type transforms
GuideWebhook Security — Delivery, Verification, and Protection
A practical guide to securing webhooks: signature verification, replay attack prevention, payload encryption, and endpoint hardening for reliable delivery.