Zod Validation Skill
Summary
TypeScript-first schema validation library with static type inference. Define schemas once, get runtime validation and compile-time types automatically.
When to Use
-
Form validation with type-safe data
-
API request/response validation
-
Environment variable validation
-
Runtime type checking with TypeScript inference
-
tRPC procedure inputs/outputs
-
Database schema validation (Drizzle, Prisma)
Quick Start
import { z } from 'zod';
// Define schema const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), age: z.number().min(18), role: z.enum(['user', 'admin']) });
// Infer TypeScript type type User = z.infer<typeof UserSchema>;
// Validate data const result = UserSchema.safeParse(data); if (result.success) { const user: User = result.data; }
Primitive Types
Basic Types
import { z } from 'zod';
// String with validation const nameSchema = z.string() .min(2, "Too short") .max(50, "Too long") .trim();
const emailSchema = z.string().email(); const urlSchema = z.string().url(); const uuidSchema = z.string().uuid(); const regexSchema = z.string().regex(/^[A-Z]{3}$/);
// Numbers const ageSchema = z.number() .int("Must be integer") .positive() .min(0) .max(120);
const priceSchema = z.number() .positive() .multipleOf(0.01); // Currency precision
// Boolean const isActiveSchema = z.boolean();
// Date const createdAtSchema = z.date() .min(new Date('2020-01-01')) .max(new Date());
const dateStringSchema = z.string().datetime(); // ISO 8601 const dateOnlySchema = z.string().date(); // YYYY-MM-DD
Special Types
// Literal values const roleSchema = z.literal('admin'); const statusSchema = z.literal('pending');
// Enums const ColorEnum = z.enum(['red', 'green', 'blue']); type Color = z.infer<typeof ColorEnum>; // 'red' | 'green' | 'blue'
const NativeEnum = z.nativeEnum(MyEnum); // For TypeScript enums
// Nullable and Optional const optionalString = z.string().optional(); // string | undefined const nullableString = z.string().nullable(); // string | null const nullishString = z.string().nullish(); // string | null | undefined
// Default values const countSchema = z.number().default(0); const settingsSchema = z.object({ theme: z.string().default('light'), notifications: z.boolean().default(true) });
Objects and Arrays
Object Schemas
// Basic object const UserSchema = z.object({ id: z.string(), email: z.string().email(), name: z.string(), age: z.number().optional() });
// Nested objects const AddressSchema = z.object({ street: z.string(), city: z.string(), country: z.string(), zipCode: z.string() });
const PersonSchema = z.object({ name: z.string(), address: AddressSchema, contacts: z.object({ email: z.string().email(), phone: z.string().optional() }) });
// Strict vs Passthrough const strictSchema = z.object({ name: z.string() }).strict(); // Rejects unknown keys
const passthroughSchema = z.object({ name: z.string() }).passthrough(); // Allows unknown keys
const stripSchema = z.object({ name: z.string() }).strip(); // Removes unknown keys (default)
Array Schemas
// Simple arrays const stringArray = z.array(z.string()); const numberArray = z.array(z.number()).min(1).max(10);
// Array of objects const UsersSchema = z.array(UserSchema);
// Non-empty arrays const tagSchema = z.array(z.string()).nonempty("At least one tag required");
// Fixed-length arrays (tuples) const coordinateSchema = z.tuple([z.number(), z.number()]); type Coordinate = z.infer<typeof coordinateSchema>; // [number, number]
// Tuple with rest const csvRowSchema = z.tuple([z.string(), z.number()]).rest(z.string()); // [string, number, ...string[]]
Records and Maps
// Record (object with dynamic keys) const userRolesSchema = z.record( z.string(), // key type z.enum(['admin', 'user', 'guest']) // value type ); type UserRoles = z.infer<typeof userRolesSchema>; // { [key: string]: 'admin' | 'user' | 'guest' }
// Map const configMapSchema = z.map( z.string(), // key z.number() // value );
// Set const uniqueTagsSchema = z.set(z.string());
Type Inference
import { z } from 'zod';
// Infer output type const UserSchema = z.object({ id: z.string(), email: z.string().email(), age: z.number() });
type User = z.infer<typeof UserSchema>; // { id: string; email: string; age: number }
// Infer input type (before transforms) const TransformSchema = z.object({ date: z.string().transform(s => new Date(s)) });
type Input = z.input<typeof TransformSchema>; // { date: string }
type Output = z.output<typeof TransformSchema>; // { date: Date }
// Using inferred types in functions function createUser(data: User): void { // data is type-safe }
function validateAndCreate(data: unknown): User | null { const result = UserSchema.safeParse(data); return result.success ? result.data : null; }
Validation Methods
Parse vs SafeParse
// parse() - Throws on failure try { const user = UserSchema.parse(data); // user is type User } catch (error) { if (error instanceof z.ZodError) { console.error(error.issues); } }
// safeParse() - Returns result object const result = UserSchema.safeParse(data);
if (result.success) {
const user = result.data; // type User
} else {
const errors = result.error.issues;
errors.forEach(err => {
console.log(${err.path}: ${err.message});
});
}
// parseAsync() - For async refinements const asyncResult = await UserSchema.parseAsync(data);
// safeParseAsync() - Safe async version const asyncSafeResult = await UserSchema.safeParseAsync(data);
Partial Validation
// Check if data matches schema without throwing const isValid = UserSchema.safeParse(data).success;
// Custom type guards function isUser(data: unknown): data is User { return UserSchema.safeParse(data).success; }
if (isUser(unknownData)) { // TypeScript knows unknownData is User console.log(unknownData.email); }
Schema Composition
Extending and Merging
// Extend (add fields) const BaseUserSchema = z.object({ id: z.string(), email: z.string() });
const AdminUserSchema = BaseUserSchema.extend({ role: z.literal('admin'), permissions: z.array(z.string()) });
// Merge (combine schemas) const NameSchema = z.object({ name: z.string() }); const AgeSchema = z.object({ age: z.number() });
const PersonSchema = NameSchema.merge(AgeSchema); // { name: string; age: number }
// Pick (select fields) const UserIdEmail = UserSchema.pick({ id: true, email: true });
// Omit (exclude fields) const UserWithoutId = UserSchema.omit({ id: true });
// Partial (make all fields optional) const PartialUser = UserSchema.partial();
// DeepPartial (recursive partial) const DeepPartialUser = UserSchema.deepPartial();
// Required (make all fields required) const RequiredUser = UserSchema.required();
Union and Intersection
// Union (OR) const StringOrNumber = z.union([z.string(), z.number()]); // Shorthand const StringOrNumberAlt = z.string().or(z.number());
// Discriminated Union (tagged union) const SuccessResponse = z.object({ status: z.literal('success'), data: z.any() });
const ErrorResponse = z.object({ status: z.literal('error'), message: z.string() });
const ApiResponse = z.discriminatedUnion('status', [ SuccessResponse, ErrorResponse ]);
// Intersection (AND) const User = z.object({ name: z.string() }); const Timestamps = z.object({ createdAt: z.date(), updatedAt: z.date() });
const UserWithTimestamps = z.intersection(User, Timestamps); // Shorthand const UserWithTimestampsAlt = User.and(Timestamps);
Transformations and Refinements
Transform
// Transform data after validation const StringToNumber = z.string().transform(val => parseInt(val, 10));
const DateSchema = z.string().transform(str => new Date(str));
// Chaining transforms const TrimmedLowercase = z.string() .transform(s => s.trim()) .transform(s => s.toLowerCase());
// Transform with validation const PositiveStringNumber = z.string() .transform(val => parseInt(val, 10)) .refine(n => n > 0, "Must be positive");
// Complex transformations const UserInputSchema = z.object({ name: z.string().transform(s => s.trim()), email: z.string().email().transform(s => s.toLowerCase()), birthDate: z.string().transform(s => new Date(s)), tags: z.string().transform(s => s.split(',').map(t => t.trim())) });
type UserInput = z.input<typeof UserInputSchema>; // { name: string; email: string; birthDate: string; tags: string }
type User = z.output<typeof UserInputSchema>; // { name: string; email: string; birthDate: Date; tags: string[] }
Refine (Custom Validation)
// Simple refinement const PasswordSchema = z.string() .min(8) .refine( val => /[A-Z]/.test(val), "Must contain uppercase letter" ) .refine( val => /[0-9]/.test(val), "Must contain number" );
// Refinement with custom error const UniqueEmailSchema = z.string().email().refine( async (email) => { const exists = await checkEmailExists(email); return !exists; }, { message: "Email already taken" } );
// Object-level refinement const PasswordMatchSchema = z.object({ password: z.string(), confirmPassword: z.string() }).refine( data => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"] // Error location } );
// Multiple field validation const DateRangeSchema = z.object({ startDate: z.date(), endDate: z.date() }).refine( data => data.endDate > data.startDate, { message: "End date must be after start date", path: ["endDate"] } );
SuperRefine (Advanced)
// Access to Zod context for complex validation const ComplexSchema = z.object({ type: z.enum(['email', 'phone']), value: z.string() }).superRefine((data, ctx) => { if (data.type === 'email') { const emailRegex = /^[^\s@]+@[^\s@]+.[^\s@]+$/; if (!emailRegex.test(data.value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid email format", path: ["value"] }); } } else if (data.type === 'phone') { const phoneRegex = /^+?[1-9]\d{1,14}$/; if (!phoneRegex.test(data.value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid phone format", path: ["value"] }); } } });
// Multiple issues const RegistrationSchema = z.object({ username: z.string(), email: z.string(), age: z.number() }).superRefine(async (data, ctx) => { // Check username availability if (await usernameTaken(data.username)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Username taken", path: ["username"] }); }
// Check email availability if (await emailTaken(data.email)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Email already registered", path: ["email"] }); }
// Age restriction if (data.age < 18) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Must be 18 or older", path: ["age"] }); } });
Error Handling
Custom Error Messages
// Field-level messages const UserSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), age: z.number({ required_error: "Age is required", invalid_type_error: "Age must be a number" }).min(18, { message: "Must be 18 or older" }) });
// Global error map import { z } from 'zod';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "This field must be text" };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === "string") {
return { message: Minimum ${issue.minimum} characters required };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
Processing Errors
// Flatten errors for forms const result = UserSchema.safeParse(data);
if (!result.success) { const flatErrors = result.error.flatten();
console.log(flatErrors.formErrors); // Top-level errors console.log(flatErrors.fieldErrors); // { email: ["Invalid email"], age: ["Must be 18+"] } }
// Format for API response function formatZodError(error: z.ZodError) { return error.issues.map(issue => ({ field: issue.path.join('.'), message: issue.message })); }
// Example usage const result = UserSchema.safeParse(data); if (!result.success) { return res.status(400).json({ errors: formatZodError(result.error) }); }
Async Validation
import { z } from 'zod';
// Async refinement const UsernameSchema = z.string().refine( async (username) => { const available = await checkUsernameAvailable(username); return available; }, { message: "Username already taken" } );
// Must use parseAsync or safeParseAsync const result = await UsernameSchema.safeParseAsync("john_doe");
// Complex async validation const RegistrationSchema = z.object({ username: z.string().refine( async (val) => !(await usernameTaken(val)), "Username taken" ), email: z.string().email().refine( async (val) => !(await emailTaken(val)), "Email already registered" ), inviteCode: z.string().refine( async (code) => await validateInviteCode(code), "Invalid invite code" ) });
// Validate const userData = await RegistrationSchema.parseAsync(input);
// With error handling const result = await RegistrationSchema.safeParseAsync(input); if (!result.success) { // Handle validation errors }
Advanced Types
Recursive Types
// Self-referential schemas type Category = { name: string; subcategories: Category[]; };
const CategorySchema: z.ZodType<Category> = z.lazy(() => z.object({ name: z.string(), subcategories: z.array(CategorySchema) }) );
// Tree structure type TreeNode = { value: number; left?: TreeNode; right?: TreeNode; };
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() => z.object({ value: z.number(), left: TreeNodeSchema.optional(), right: TreeNodeSchema.optional() }) );
Discriminated Unions
// Type-safe union based on discriminator field const Circle = z.object({ kind: z.literal('circle'), radius: z.number() });
const Rectangle = z.object({ kind: z.literal('rectangle'), width: z.number(), height: z.number() });
const Triangle = z.object({ kind: z.literal('triangle'), base: z.number(), height: z.number() });
const Shape = z.discriminatedUnion('kind', [ Circle, Rectangle, Triangle ]);
type Shape = z.infer<typeof Shape>;
// TypeScript can narrow based on discriminator function calculateArea(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'rectangle': return shape.width * shape.height; case 'triangle': return (shape.base * shape.height) / 2; } }
Preprocess
// Transform before validation const NumberFromString = z.preprocess( (val) => (typeof val === 'string' ? parseInt(val, 10) : val), z.number() );
// Clean data before validation const TrimmedString = z.preprocess( (val) => (typeof val === 'string' ? val.trim() : val), z.string() );
// Parse JSON strings const JsonSchema = z.preprocess( (val) => (typeof val === 'string' ? JSON.parse(val) : val), z.object({ name: z.string(), age: z.number() }) );
// Form data preprocessing const FormDataSchema = z.preprocess( (data) => { // Convert FormData to object if (data instanceof FormData) { return Object.fromEntries(data.entries()); } return data; }, z.object({ name: z.string(), email: z.string().email() }) );
Branded Types
// Create nominal types const UserId = z.string().uuid().brand<'UserId'>(); type UserId = z.infer<typeof UserId>;
const Email = z.string().email().brand<'Email'>(); type Email = z.infer<typeof Email>;
// Prevents mixing similar types function getUserById(id: UserId) { /* ... / } function sendEmail(to: Email) { / ... */ }
const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000'); const email = Email.parse('user@example.com');
getUserById(userId); // ✓ getUserById(email); // ✗ Type error
Integrations
React Hook Form
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod';
const FormSchema = z.object({ username: z.string().min(3, "Minimum 3 characters"), email: z.string().email("Invalid email"), age: z.number().min(18, "Must be 18+") });
type FormData = z.infer<typeof FormSchema>;
function MyForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(FormSchema) });
const onSubmit = (data: FormData) => { // data is validated and typed console.log(data); };
return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('username')} /> {errors.username && <span>{errors.username.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Submit</button>
</form>
); }
tRPC
import { z } from 'zod'; import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const router = t.router; const publicProcedure = t.procedure;
// Input/output validation const appRouter = router({ userById: publicProcedure .input(z.object({ id: z.string().uuid() })) .output(z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email() })) .query(async ({ input }) => { const user = await db.user.findUnique({ where: { id: input.id } }); return user; // Type-checked against output schema }),
createUser: publicProcedure .input(z.object({ name: z.string().min(2), email: z.string().email(), age: z.number().min(18) })) .mutation(async ({ input }) => { return await db.user.create({ data: input }); }) });
export type AppRouter = typeof appRouter;
Next.js API Routes
// app/api/users/route.ts import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod';
const CreateUserSchema = z.object({ name: z.string().min(2), email: z.string().email(), age: z.number().min(18).optional() });
export async function POST(request: NextRequest) { try { const body = await request.json(); const validatedData = CreateUserSchema.parse(body);
// validatedData is typed and validated
const user = await createUser(validatedData);
return NextResponse.json(user, { status: 201 });
} catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { errors: error.flatten().fieldErrors }, { status: 400 } ); } return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }
// Query parameter validation const SearchParamsSchema = z.object({ page: z.string().transform(Number).pipe(z.number().min(1)).default('1'), limit: z.string().transform(Number).pipe(z.number().max(100)).default('10'), sort: z.enum(['asc', 'desc']).default('asc') });
export async function GET(request: NextRequest) { const searchParams = Object.fromEntries( request.nextUrl.searchParams.entries() );
const params = SearchParamsSchema.parse(searchParams); // params is { page: number, limit: number, sort: 'asc' | 'desc' }
const users = await getUsers(params); return NextResponse.json(users); }
Express Middleware
import express from 'express'; import { z } from 'zod';
// Validation middleware const validate = (schema: z.ZodSchema) => { return (req: express.Request, res: express.Response, next: express.NextFunction) => { try { schema.parse(req.body); next(); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ errors: error.flatten().fieldErrors }); } next(error); } }; };
const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number().min(18) });
app.post('/users', validate(CreateUserSchema), async (req, res) => { // req.body is validated (not typed in Express) const user = await createUser(req.body); res.json(user); });
// Validate params, query, body const validateRequest = (schema: { params?: z.ZodSchema; query?: z.ZodSchema; body?: z.ZodSchema; }) => { return (req: express.Request, res: express.Response, next: express.NextFunction) => { try { if (schema.params) { req.params = schema.params.parse(req.params); } if (schema.query) { req.query = schema.query.parse(req.query); } if (schema.body) { req.body = schema.body.parse(req.body); } next(); } catch (error) { if (error instanceof z.ZodError) { return res.status(400).json({ errors: error.issues }); } next(error); } }; };
app.get( '/users/:id', validateRequest({ params: z.object({ id: z.string().uuid() }), query: z.object({ include: z.string().optional() }) }), async (req, res) => { // Validated params and query } );
Drizzle ORM
import { z } from 'zod'; import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
// Define table export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), age: integer('age') });
// Auto-generate schemas export const insertUserSchema = createInsertSchema(users); export const selectUserSchema = createSelectSchema(users);
// Customize validation export const customInsertUserSchema = createInsertSchema(users, { email: z.string().email(), age: z.number().min(18).optional() });
// Use in application type NewUser = z.infer<typeof insertUserSchema>; type User = z.infer<typeof selectUserSchema>;
function createUser(data: unknown) { const validatedData = insertUserSchema.parse(data); return db.insert(users).values(validatedData); }
Environment Variables
// env.ts import { z } from 'zod';
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), DATABASE_URL: z.string().url(), API_KEY: z.string().min(32), PORT: z.string().transform(Number).pipe(z.number().min(1024)), REDIS_HOST: z.string().default('localhost'), REDIS_PORT: z.string().transform(Number).default('6379'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info') });
// Validate on startup export const env = envSchema.parse(process.env);
// Type-safe environment variables export type Env = z.infer<typeof envSchema>;
// Usage
console.log(Server running on port ${env.PORT});
// env.PORT is number, not string
Best Practices
Schema Organization
// schemas/user.schema.ts import { z } from 'zod';
// Reusable primitives export const emailSchema = z.string().email(); export const uuidSchema = z.string().uuid(); export const passwordSchema = z.string() .min(8) .regex(/[A-Z]/, "Must contain uppercase") .regex(/[0-9]/, "Must contain number");
// Base schemas export const baseUserSchema = z.object({ id: uuidSchema, email: emailSchema, name: z.string().min(2) });
// Extended schemas export const createUserSchema = baseUserSchema.omit({ id: true }).extend({ password: passwordSchema, confirmPassword: z.string() }).refine( data => data.password === data.confirmPassword, { message: "Passwords must match", path: ["confirmPassword"] } );
export const updateUserSchema = baseUserSchema.partial().omit({ id: true });
// Export types export type User = z.infer<typeof baseUserSchema>; export type CreateUser = z.infer<typeof createUserSchema>; export type UpdateUser = z.infer<typeof updateUserSchema>;
Performance Optimization
// Cache parsed schemas const userSchemaCache = new Map<string, z.ZodSchema>();
function getCachedSchema(key: string, factory: () => z.ZodSchema) { if (!userSchemaCache.has(key)) { userSchemaCache.set(key, factory()); } return userSchemaCache.get(key)!; }
// Lazy validation for large objects const lazyUserSchema = z.lazy(() => z.object({ // Only validated when accessed profile: complexProfileSchema, settings: complexSettingsSchema }));
// Streaming validation for arrays async function validateLargeArray(items: unknown[]) { const errors: z.ZodError[] = [];
for (const item of items) { const result = ItemSchema.safeParse(item); if (!result.success) { errors.push(result.error); } }
return errors; }
Testing Schemas
import { describe, it, expect } from 'vitest';
describe('UserSchema', () => { it('validates correct user data', () => { const validUser = { email: 'user@example.com', name: 'John Doe', age: 25 };
expect(() => UserSchema.parse(validUser)).not.toThrow();
});
it('rejects invalid email', () => { const invalidUser = { email: 'not-an-email', name: 'John', age: 25 };
const result = UserSchema.safeParse(invalidUser);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['email']);
}
});
it('applies transforms correctly', () => { const input = { name: ' JOHN DOE ', email: 'USER@EXAMPLE.COM' };
const result = UserSchema.parse(input);
expect(result.name).toBe('john doe');
expect(result.email).toBe('user@example.com');
}); });
Common Patterns
// Conditional validation const ConditionalSchema = z.object({ type: z.enum(['personal', 'business']), data: z.any() }).transform((val) => { if (val.type === 'personal') { return { type: val.type, data: PersonalDataSchema.parse(val.data) }; } else { return { type: val.type, data: BusinessDataSchema.parse(val.data) }; } });
// Pagination schema export const paginationSchema = z.object({ page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), sort: z.string().optional(), order: z.enum(['asc', 'desc']).default('asc') });
// Filter schema export const filterSchema = z.object({ search: z.string().optional(), status: z.enum(['active', 'inactive', 'pending']).optional(), dateFrom: z.string().datetime().optional(), dateTo: z.string().datetime().optional() });
// API response wrapper export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => z.object({ success: z.boolean(), data: dataSchema.optional(), error: z.string().optional(), timestamp: z.string().datetime() });
const userResponseSchema = apiResponseSchema(UserSchema);
Migration from Yup/Joi
// Yup -> Zod // Yup const yupSchema = yup.object({ email: yup.string().email().required(), age: yup.number().min(18).required() });
// Zod equivalent const zodSchema = z.object({ email: z.string().email(), age: z.number().min(18) });
// Joi -> Zod // Joi const joiSchema = Joi.object({ email: Joi.string().email().required(), age: Joi.number().min(18).required() });
// Zod equivalent (same as above) const zodSchema = z.object({ email: z.string().email(), age: z.number().min(18) });
// Key differences: // 1. Zod fields are required by default // 2. Zod has first-class TypeScript integration // 3. Zod schemas are immutable // 4. Zod has better tree-shaking
Additional Resources
-
Zod Documentation
-
Zod GitHub
-
TypeScript Deep Dive
-
tRPC + Zod
-
React Hook Form + Zod