Zod: Stop Trusting Your API Responses at Runtime

TypeScript is great right up until the moment your app actually runs. Types are erased by the compiler, so when a REST endpoint returns an unexpected shape or an environment variable is missing, TypeScript can't save you — a runtime crash or silent bug will. That's the gap Zod fills.
What Zod Is (and Isn't)
Zod is a TypeScript-first schema declaration and validation library. You define the shape of your data once, and Zod gives you:
- Runtime parsing — it throws (or returns an error) if the data doesn't match.
- Static TypeScript types — inferred automatically from the same schema, so you have one source of truth.
It has no runtime dependencies and works in Node, Deno, Bun, and every browser.
1npm install zod 2
Basic Shapes
1import { z } from 'zod'; 2 3// Primitives 4const nameSchema = z.string().min(1).max(100); 5const ageSchema = z.number().int().positive(); 6const flagSchema = z.boolean(); 7 8// Object 9const userSchema = z.object({ 10 id: z.string().uuid(), 11 name: z.string().min(1), 12 email: z.string().email(), 13 age: z.number().int().min(0).optional(), 14}); 15 16// Array of objects 17const usersSchema = z.array(userSchema); 18
Types are inferred with
:1z.infer
1type User = z.infer<typeof userSchema>; 2// { id: string; name: string; email: string; age?: number } 3
You define the schema, TypeScript follows along — no duplication, no drift.
1.parse()
vs 1.safeParse()
1.parse()1.safeParse()throws a1parse
on failure;1ZodError
returns a discriminated union so you can handle errors without try/catch:1safeParse
1// Throws if invalid 2const user = userSchema.parse(rawData); 3 4// Returns { success: true, data } or { success: false, error } 5const result = userSchema.safeParse(rawData); 6 7if (!result.success) { 8 console.error(result.error.flatten()); 9 // { fieldErrors: { email: ['Invalid email'] }, formErrors: [] } 10} else { 11 console.log(result.data.name); // fully typed 12} 13
I reach for
in production code and1safeParse
in scripts where I want hard failures.1parse
Validating API Responses
This is where Zod earns its keep. Here's a pattern I use in every Next.js project:
1// lib/schemas.ts 2import { z } from 'zod'; 3 4export const postSchema = z.object({ 5 id: z.number(), 6 title: z.string(), 7 body: z.string(), 8 userId: z.number(), 9}); 10 11export const postsSchema = z.array(postSchema); 12export type Post = z.infer<typeof postSchema>; 13
1// lib/api.ts 2import { postsSchema } from './schemas'; 3 4export async function fetchPosts() { 5 const res = await fetch('https://jsonplaceholder.typicode.com/posts'); 6 if (!res.ok) throw new Error(`HTTP ${res.status}`); 7 8 const raw = await res.json(); 9 const result = postsSchema.safeParse(raw); 10 11 if (!result.success) { 12 // Log schema mismatch, alert Sentry, whatever you do 13 console.error('Unexpected API shape:', result.error.flatten()); 14 throw new Error('API response did not match expected shape'); 15 } 16 17 return result.data; // Post[] — fully typed, validated 18} 19
If the API team renames
to1userId
in a deploy on Friday at 4 PM, your code catches it at the boundary instead of silently passing1user_id
ten levels deep.1undefined
Validating Environment Variables
Another pattern I rely on constantly — especially for homelab projects with
files. Put this in a dedicated module that you import early:1.env
1// lib/env.ts 2import { z } from 'zod'; 3 4const envSchema = z.object({ 5 DATABASE_URL: z.string().url(), 6 API_SECRET: z.string().min(16), 7 PORT: z.coerce.number().int().positive().default(3000), 8 NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), 9}); 10 11// parse() throws at startup with a clear error if something is missing 12export const env = envSchema.parse(process.env); 13
converts the string1z.coerce.number()
to1"3000"
automatically — env vars are always strings, so this is the right tool. If13000
is missing you get:1DATABASE_URL
1ZodError: [ 2 { path: ['DATABASE_URL'], message: 'Invalid url' } 3] 4
…at startup, not hours into a debugging session.
Transforms and Refinements
Zod can reshape data as it validates:
1const dateSchema = z 2 .string() 3 .datetime() 4 .transform((s) => new Date(s)); 5 6// result.data is a Date, not a string 7
And add custom validation logic:
1const passwordSchema = z 2 .string() 3 .min(8) 4 .refine((val) => /[A-Z]/.test(val), { 5 message: 'Must contain at least one uppercase letter', 6 }); 7
Zod + React Hook Form
If you use React Hook Form you get a clean pair via the official resolver:
1npm install @hookform/resolvers 2
1import { useForm } from 'react-hook-form'; 2import { zodResolver } from '@hookform/resolvers/zod'; 3import { z } from 'zod'; 4 5const schema = z.object({ 6 email: z.string().email(), 7 password: z.string().min(8), 8}); 9 10type FormData = z.infer<typeof schema>; 11 12export function LoginForm() { 13 const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ 14 resolver: zodResolver(schema), 15 }); 16 17 return ( 18 <form onSubmit={handleSubmit((data) => console.log(data))}> 19 <input {...register('email')} /> 20 {errors.email && <p>{errors.email.message}</p>} 21 22 <input type="password" {...register('password')} /> 23 {errors.password && <p>{errors.password.message}</p>} 24 25 <button type="submit">Login</button> 26 </form> 27 ); 28} 29
One schema drives client-side form validation and the types — no duplication between your form state and your API handler.
When I Reach for Zod
- Any data crossing a trust boundary — external APIs, form submissions, URL params, webhooks, database rows via raw queries.
- Environment configuration — validate at startup, not at usage.
- tRPC or a typed API layer — Zod is the native input/output validator, so if you're using tRPC you're already using Zod.
I don't Zod-ify every internal function call — that would be noise. But anything that arrives from outside your code is fair game, and the cost is a few lines of schema definition that also give you your TypeScript types for free.
Wrapping Up
Zod solves a real gap: TypeScript can tell you a variable should be a string, but only Zod can tell you it is one at runtime. Defining schemas at API and config boundaries has caught real bugs before they hit production for me, and the free type inference means I'm not maintaining types and validators separately. If you're already using TypeScript, Zod is one of the easiest wins you can add to any project today.
