Zod: Stop Trusting Your API Responses at Runtime

5 min read

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:

  1. Runtime parsing — it throws (or returns an error) if the data doesn't match.
  2. 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()

1parse
throws a
1ZodError
on failure;
1safeParse
returns a discriminated union so you can handle errors without try/catch:

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

1safeParse
in production code and
1parse
in scripts where I want hard failures.

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

1userId
to
1user_id
in a deploy on Friday at 4 PM, your code catches it at the boundary instead of silently passing
1undefined
ten levels deep.

Validating Environment Variables

Another pattern I rely on constantly — especially for homelab projects with

1.env
files. Put this in a dedicated module that you import early:

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

1z.coerce.number()
converts the string
1"3000"
to
13000
automatically — env vars are always strings, so this is the right tool. If
1DATABASE_URL
is missing you get:

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.