Zod Complete Guide: TypeScript-First Schema Validation Library
이 글의 핵심
Zod is a TypeScript-first schema validation library that automatically infers types from schemas. Unlike Yup and Joi, Zod provides zero-dependency, tree-shakable validation with perfect TypeScript integration. Default choice for tRPC, React Hook Form, and T3 Stack with 35k+ GitHub stars.
What is Zod?
Zod is a TypeScript-first schema validation library. While Yup and Joi require runtime validation + manual type definitions, Zod automatically infers TypeScript types from schemas and is tree-shakable with zero dependencies for smaller bundles.
Launched in 2020, Zod became the default validation library for tRPC, React Hook Form, and T3 Stack, reaching 35k+ GitHub stars by 2026.
This guide covers everything from basic API to schema composition, advanced unions and recursion, transform & preprocess, error strategies, form library comparison, API design patterns, performance & lazy, and Yup/Joi comparison for production use.
All examples use TypeScript 5.x.
Installation
npm install zod
Basic Usage
import { z } from 'zod';
// Define schema
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// Infer type
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string; }
// Validate (throws on error)
const user = UserSchema.parse({
name: 'Alice',
age: 30,
email: '[email protected]',
});
// Safe validation (returns result)
const result = UserSchema.safeParse({
name: 'Bob',
age: 'invalid', // Type error
email: '[email protected]',
});
if (!result.success) {
console.log(result.error.issues);
} else {
console.log(result.data);
}
Production Tip: Use parse only at boundaries (HTTP handlers, queue consumers, main). Use safeParse for library internals and UI events to control flow explicitly.
Primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
String Validation
z.string()
.min(3)
.max(100)
.email()
.url()
.uuid()
.regex(/^[A-Z]+$/)
.trim()
.toLowerCase()
.toUpperCase()
.datetime() // ISO 8601
.ip() // IPv4 or IPv6
Number Validation
z.number()
.min(0)
.max(100)
.int()
.positive()
.negative()
.nonnegative()
.nonpositive()
.multipleOf(5)
.finite()
.safe()
Object Schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.date().optional(),
});
// Partial (all fields optional)
const PartialUser = UserSchema.partial();
// Pick
const UserIdName = UserSchema.pick({ id: true, name: true });
// Omit
const UserWithoutId = UserSchema.omit({ id: true });
// Extend
const ExtendedUser = UserSchema.extend({
phoneNumber: z.string(),
});
// Merge
const MergedSchema = UserSchema.merge(AnotherSchema);
Arrays and Tuples
// Array
const TagsSchema = z.array(z.string());
const tags = TagsSchema.parse(['typescript', 'zod']);
// Non-empty array
const NonEmptyTags = z.array(z.string()).nonempty();
// Min/Max length
const LimitedTags = z.array(z.string()).min(1).max(5);
// Tuple
const CoordinateSchema = z.tuple([z.number(), z.number()]);
const coord = CoordinateSchema.parse([10, 20]);
// Tuple with rest
const MixedTuple = z.tuple([z.string(), z.number()]).rest(z.boolean());
Unions and Enums
// Union
const StringOrNumber = z.union([z.string(), z.number()]);
// Discriminated union (recommended)
const ResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.any() }),
z.object({ status: z.literal('error'), error: z.string() }),
]);
// Enum
const RoleSchema = z.enum(['admin', 'user', 'guest']);
type Role = z.infer<typeof RoleSchema>; // 'admin' | 'user' | 'guest'
// Native enum
enum NativeRole {
Admin = 'ADMIN',
User = 'USER',
}
const NativeRoleSchema = z.nativeEnum(NativeRole);
Transform and Preprocess
Transform
const DateSchema = z.string().transform((str) => new Date(str));
const date = DateSchema.parse('2024-01-01'); // Date object
// With validation
const PositiveNumber = z.number()
.transform((val) => Math.abs(val))
.pipe(z.number().positive());
Preprocess
const CoerceNumber = z.preprocess(
(val) => Number(val),
z.number()
);
const num = CoerceNumber.parse('42'); // 42 (number)
Optional, Nullable, Default
// Optional (T | undefined)
const OptionalString = z.string().optional();
// Nullable (T | null)
const NullableString = z.string().nullable();
// Both (T | null | undefined)
const NullishString = z.string().nullish();
// Default value
const WithDefault = z.string().default('default value');
// Catch (fallback on error)
const WithCatch = z.string().catch('fallback');
React Hook Form Integration
npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginForm = z.infer<typeof LoginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
});
const onSubmit = (data: LoginForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
tRPC Integration
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
createUser: t.procedure
.input(
z.object({
name: z.string(),
email: z.string().email(),
})
)
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
});
Error Handling
Custom Error Messages
const UserSchema = z.object({
email: z.string().email('Please provide a valid email address'),
age: z.number().min(18, 'Must be at least 18 years old'),
});
Error Map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'This field must be text' };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
Format Errors
const result = UserSchema.safeParse(data);
if (!result.success) {
const formatted = result.error.format();
console.log(formatted.email?._errors);
console.log(formatted.age?._errors);
}
Recursive Schemas
interface Category {
name: string;
subcategories: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);
Performance: Lazy Validation
// Expensive schema
const ExpensiveSchema = z.lazy(() =>
z.object({
// Heavy computation
})
);
// Only validates when called
const result = ExpensiveSchema.parse(data);
Comparison: Zod vs Yup vs Joi
| Feature | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript | Native | Partial | Plugin |
| Type Inference | Automatic | Manual | Manual |
| Bundle Size | 14KB | 20KB | 100KB+ |
| Tree-shakable | Yes | No | No |
| Browser Support | Yes | Yes | No |
| Async Validation | Yes | Yes | Yes |
| Transform | Yes | Yes | Yes |
| tRPC Support | Built-in | No | No |
| Learning Curve | Low | Medium | Medium |
When to use:
- Zod: New TypeScript projects, tRPC, modern React apps
- Yup: Legacy projects, existing Formik integration
- Joi: Node.js-only, Hapi framework
Production Best Practices
API Input Validation
// Express example
app.post('/api/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues,
});
}
const user = await createUser(result.data);
res.json(user);
});
Environment Variables
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
PORT: z.string().transform(Number).pipe(z.number().positive()),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
const env = EnvSchema.parse(process.env);
export default env;
Reusable Schemas
// schemas/user.ts
export const UserIdSchema = z.number().positive();
export const EmailSchema = z.string().email().toLowerCase();
export const UserNameSchema = z.string().min(2).max(50);
export const CreateUserSchema = z.object({
email: EmailSchema,
name: UserNameSchema,
});
export const UpdateUserSchema = CreateUserSchema.partial();
Summary & Checklist
Key Points
- TypeScript-First: Automatic type inference from schemas
- Small Bundle: 14KB, tree-shakable, zero dependencies
- tRPC Integration: Default validation library
- Form Libraries: Works with React Hook Form, Formik
- Production-Ready: Used by Vercel, Prisma, T3 Stack
Implementation Checklist
- Install Zod
- Define schemas with proper validation
- Infer TypeScript types with
z.infer - Use
safeParsefor controlled error handling - Integrate with React Hook Form or tRPC
- Add custom error messages
- Validate environment variables
- Create reusable schema modules
Related Articles
- [tRPC Complete Guide](/en/blog/trpc-complete-guide/
- [React Hook Form Guide](/en/blog/react-hook-form-complete-guide/
- [Prisma Complete Guide](/en/blog/prisma-complete-guide/
Keywords
Zod, TypeScript, Validation, Schema, Type Safety, tRPC, React, Form Validation
Frequently Asked Questions (FAQ)
Q. Can I use Zod without TypeScript?
A. Yes, but you lose the main benefit (type inference). Zod works in plain JavaScript for runtime validation only.
Q. How to validate file uploads?
A. Use z.instanceof(File) for browser File objects or custom validation with .refine() for size/type checks.
Q. Does Zod support async validation?
A. Yes, use .refine() or .superRefine() with async functions. However, prefer synchronous validation when possible for performance.
Q. How to migrate from Yup to Zod?
A. Replace Yup schemas with equivalent Zod syntax. Most concepts map directly. Use z.infer instead of InferType. Update error handling from ValidationError to Zod’s error format.
Q. Can I use Zod for database schemas?
A. Zod validates runtime data. For database schemas, use Prisma or TypeORM. However, Zod works great for validating API inputs before database operations.
Q. How to handle nested validation errors?
A. Use result.error.flatten() or result.error.format() to structure nested errors for UI display.
Production Operations
Monitoring Validation Failures
const result = UserSchema.safeParse(data);
if (!result.success) {
logger.warn('Validation failed', {
schema: 'UserSchema',
errors: result.error.issues,
input: sanitize(data),
});
}
Performance Considerations
- Lazy schemas for recursive or expensive validation
- Cache parsed results for static config
- Validate at boundaries (API entry points) only
- Use discriminated unions instead of
.or()for better performance
Common Pitfalls
| Issue | Cause | Solution |
|---|---|---|
| Slow validation | Excessive .refine() calls | Combine checks, use built-in validators |
| Type mismatch | Missing .transform() | Add transform for type coercion |
| Circular dependency | Recursive types | Use z.lazy() |
| Memory leak | Cached schemas not released | Weak references or module scope |
For deployment: git add ??git commit ??git push ??npm run deploy.