// LOADING
// LOADING
// LOADING_ARTICLE
TypeScript's type system is erased at runtime. The User interface you carefully defined does not exist when your Next.js API route receives a JSON body — at that point, you have an unknown value and you need to decide whether it matches your expectations. Zod is the most practical library for this job: it validates at runtime and infers TypeScript types from the same schema, eliminating the need to maintain two parallel definitions.
Consider this API route:
ts// Without validation — dangerous export async function POST(req: Request) { const body = await req.json(); // type: any // TypeScript will not catch body.email being undefined await createUser(body.email, body.password); }
Casting req.json() to a type (body as CreateUserInput) gives you compile-time type checks but zero runtime guarantees. If the client sends { email: 123, password: null }, TypeScript cannot help you.
Zod validates the shape before you use it:
tsimport { z } from 'zod'; const CreateUserSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); type CreateUserInput = z.infer<typeof CreateUserSchema>; export async function POST(req: Request) { const body = await req.json(); const parsed = CreateUserSchema.safeParse(body); if (!parsed.success) { return Response.json( { error: parsed.error.flatten() }, { status: 400 } ); } // parsed.data is fully typed as CreateUserInput await createUser(parsed.data.email, parsed.data.password); }
z.infer<typeof CreateUserSchema> derives the TypeScript type from the schema. The schema is the single source of truth.
Zod covers all the primitives you need:
tsconst UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().positive().optional(), role: z.enum(['admin', 'editor', 'viewer']), createdAt: z.coerce.date(), // accepts string or Date tags: z.array(z.string()).default([]), metadata: z.record(z.string(), z.unknown()).optional(), }); type User = z.infer<typeof UserSchema>;
z.coerce.date() is especially useful for API responses where dates arrive as ISO strings. Zod coerces the string into a Date object automatically.
parse vs safeParseZod offers two parsing modes:
parse(data) — throws a ZodError if validation fails. Use it in contexts where failure is exceptional (startup config, trusted internal data).safeParse(data) — returns { success: true, data } | { success: false, error }. Use it at API boundaries where bad input is expected.ts// parse — throws on failure const config = EnvSchema.parse(process.env); // safeParse — handles failure gracefully const result = RequestBodySchema.safeParse(body); if (!result.success) { // result.error.flatten() gives field-level errors console.error(result.error.flatten()); }
One of Zod's strongest value propositions is that the same schema works for both server-side validation and client-side form validation (via react-hook-form's zodResolver):
ts// shared/schemas.ts — used by both client and server export const LoginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }); export type LoginInput = z.infer<typeof LoginSchema>;
ts// Client: React Hook Form import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { LoginSchema, LoginInput } from '@/shared/schemas'; const form = useForm<LoginInput>({ resolver: zodResolver(LoginSchema) });
ts// Server: API route import { LoginSchema } from '@/shared/schemas'; const result = LoginSchema.safeParse(await req.json());
This is exactly the pattern described in typing API responses end to end — a single schema definition shared across the stack.
Zod can transform data during parsing, not just validate it:
tsconst SlugSchema = z .string() .min(1) .transform((s) => s.toLowerCase().replace(/\s+/g, '-')); const slug = SlugSchema.parse('My Blog Post'); // 'my-blog-post'
Transformations run after validation, so the input is guaranteed valid before you transform it. The inferred output type reflects the transformation: z.infer<typeof SlugSchema> is string, but the string has been normalized.
Zod has first-class support for discriminated unions, which is faster than plain z.union because it checks the discriminant field first:
tsconst EventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('click'), x: z.number(), y: z.number() }), z.object({ type: z.literal('keypress'), key: z.string() }), z.object({ type: z.literal('scroll'), delta: z.number() }), ]); type AppEvent = z.infer<typeof EventSchema>;
Parsing an unknown event object now produces a fully narrowed AppEvent union, and the error message tells you which discriminant values are valid.
Parsing environment variables at startup is one of the highest-value uses of Zod. It converts a runtime crash deep in your application into an immediate startup error with a clear message:
tsconst EnvSchema = z.object({ MONGODB_URI: z.string().url(), JWT_SECRET: z.string().min(32), NODE_ENV: z.enum(['development', 'production', 'test']), PORT: z.coerce.number().default(3000), }); export const env = EnvSchema.parse(process.env); // env.PORT is type `number`, not `string`
This connects directly to environment variables and secrets management in Next.js, where validating process.env at startup prevents an entire class of configuration-related production incidents.
Zod error messages are structured and easy to forward to API clients:
tsconst result = Schema.safeParse(body); if (!result.success) { const errors = result.error.flatten(); // errors.fieldErrors: { email?: string[], password?: string[] } // errors.formErrors: string[] (root-level errors) return Response.json({ errors: errors.fieldErrors }, { status: 422 }); }
flatten() maps nested ZodError paths to simple field names, which is what most UI form libraries expect.
Zod adds a parsing step that takes a small amount of CPU time. For high-throughput internal APIs where both sides are TypeScript and you control the data, the overhead may not be worth it. Use it at trust boundaries: incoming HTTP requests, third-party API responses, file reads, and environment variables. For deeply trusted internal calls within the same process, direct type assertions are fine.
Zod bridges the gap between TypeScript's compile-time type system and the untyped reality of data at runtime. The key insight is that z.infer<typeof Schema> makes the schema the single source of truth — change the schema, and both the runtime validation and the TypeScript types update together. Start with your API boundaries, add it to environment validation, and share schemas between client and server for maximum value. For a broader view of TypeScript patterns in production, the interview-prep section covers related topics in depth.