Zod Showcase

Real-World Use Cases

Here we will showcase some practical patterns for real world production use. Zod is particularly useful wherever untrusted data enters your app like from forms, APIs, and even environment configuration like from .env files.

API Response Parsing

External APIs can change shape without warning. Parsing the response through a Zod schema at the boundary means any breaking change surfaces as a clear validation error rather than a mysterious undefined deep in your code. Try removing total or changing published to a string.

Schema
import { z } from 'zod';

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  published: z.boolean(),
});

const ApiResponseSchema = z.object({
  data: z.array(PostSchema),
  total: z.number(),
});

const raw = await fetch('/api/posts').then(r => r.json());
const response = ApiResponseSchema.parse(raw);
Inferred Type
type Post = {
  id: number;
  title: string;
  published: boolean;
};

type ApiResponse = {
  data: Post[];
  total: number;
}
Output
{
  "data": [
    {
      "id": 1,
      "title": "Getting started with Zod",
      "published": true
    },
    {
      "id": 2,
      "title": "Advanced patterns",
      "published": false
    }
  ],
  "total": 2
}

Parsing Unknown Data

TypeScript types only exist at compile time. JSON.parse(), localStorage, and incoming webhook payloads are all typed as any or unknown at runtime, so TypeScript cannot protect you from unexpected shapes. Passing the data through a Zod schema with z.safeParse() either gives you a fully typed result or a structured error listing exactly which fields failed and why, without throwing an exception. Try removing role or changing it to "moderator" to see what the error output looks like.

Schema
import { z } from 'zod';

const SessionSchema = z.object({
  userId: z.string().uuid(),
  name: z.string().min(1),
  role: z.enum(['admin', 'user']),
});

// JSON.parse() returns any. z.safeParse narrows it safely
const raw: unknown = JSON.parse(localStorage.getItem('session') ?? 'null');
const result = SessionSchema.safeParse(raw);

if (result.success) {
  console.log(result.data.role); // typed: 'admin' | 'user'
} else {
  console.error(result.error.issues);
}
Inferred Type
type Session = {
  userId: string;
  name: string;
  role: 'admin' | 'user';
}
Output
{
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Alice",
  "role": "admin"
}

API Response Variants

Many APIs return different shapes depending on whether a request succeeded or failed. Without a discriminator, you end up checking if data exists, if error exists, or casting to any. z.discriminatedUnion() reads a shared literal field like type to pick the right branch before validating anything else, giving faster parsing and clearer errors than z.union(). Try switching type to "error" and filling in message and code to see the other branch validate correctly.

Schema
import { z } from 'zod';

const ApiResult = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('success'),
    data: z.object({ id: z.number(), name: z.string() }),
  }),
  z.object({
    type: z.literal('error'),
    message: z.string(),
    code: z.number(),
  }),
]);
Inferred Type
type ApiResult =
  | { type: 'success'; data: { id: number; name: string } }
  | { type: 'error'; message: string; code: number }
Output
{
  "type": "success",
  "data": {
    "id": 1,
    "name": "Alice"
  }
}

Partial Schemas for Updates

PATCH endpoints only receive the fields the client wants to change, but the validation rules for each field should stay the same. .partial() makes every field in an existing schema optional so you can reuse the same base schema for both POST and PATCH routes without duplicating any logic. Try sending only email and leaving out the rest. It still passes. Try sending an invalid email to confirm the rules still apply on whatever fields are present.

Schema
import { z } from 'zod';

const ProfileSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
  bio: z.string().max(160),
});

// POST /profile   → requires all fields
// PATCH /profile  → accepts any subset, same rules
const ProfileUpdateSchema = ProfileSchema.partial();
Inferred Type
// Full schema -> all fields are required
type Profile = {
  name: string;
  email: string;
  bio: string;
};

// Using partial -> all optional but the fields have the same constraints
type ProfileUpdate = Partial<Profile>;
Output
{
  "email": "alice@example.com"
}

Transforms and Piping

Data rarely arrives in exactly the shape you need internally. Query string parameters are always strings, user input has extra whitespace, and external systems use different formats or casing. Without transforms you end up writing a separate normalisation step before every validation call. .transform() runs as part of the schema itself, so normalisation and validation happen together. Use it to normalise data by trimming strings, coercing types, and computing derived values. Chaining .pipe() after a transform feeds the result into a second validator so you can apply rules to the transformed value. Try setting limit to "0", it coerces to a number, then .pipe() rejects it as below the minimum.

Schema
import { z } from 'zod';

const SearchSchema = z.object({
  // Trim whitespace and lowercase the query
  query: z.string().transform(s => s.trim().toLowerCase()),
  // Coerce to number, then validate the number with .pipe()
  limit: z.string()
    .transform(Number)
    .pipe(z.number().int().min(1).max(100)),
});

// z.input<typeof SearchSchema>  → { query: string; limit: string }
// z.output<typeof SearchSchema> → { query: string; limit: number }
Inferred Type
// z.infer gives the OUTPUT type (after transform):
type Search = {
  query: string;
  limit: number;  // was string before transform
}
Output
{
  "query": "hello world",
  "limit": 10
}

HTML Form Parsing

HTML forms send everything as strings. Checkboxes arrive as "on" or are absent entirely, number inputs arrive as "42". Using z.preprocess() lets you normalise the raw value before the schema runs. z.coerce.number() is shorthand that calls Number() automatically. Try removing newsletter from the input to see it coerce to false.

Schema
import { z } from 'zod';

const OrderSchema = z.object({
  // Checkbox: "on" when checked, absent when not → boolean
  newsletter: z.preprocess(v => v === 'on', z.boolean()),
  // Number input always arrives as a string → coerce to number
  quantity: z.coerce.number().int().min(1).max(99),
  // Optional: empty string or absent → undefined
  discount: z.preprocess(
    v => (v === '' || v == null ? undefined : Number(v)),
    z.number().min(0).max(100).optional(),
  ),
});
Inferred Type
type Order = {
  newsletter: boolean;   // always present. False when absent
  quantity: number;      // coerced from string input
  discount?: number;     // absent when field is empty
}
Output
{
  "newsletter": true,
  "quantity": 3,
  "discount": 15
}

Environment Variable Validation

Validate process.env at startup so your app crashes early with a clear error instead of mysteriously misbehaving at runtime. All env vars are strings, so z.coerce handles conversion automatically: z.coerce.number() turns "3000" into 3000, z.coerce.boolean() turns "false" into false. Chain .default() to fill in fallbacks for non-critical variables. Try removing PORT to see the default kick in, or setting MAX_RETRY to "99" to hit the maximum.

Schema
import { z } from 'zod';

const EnvSchema = z.object({
  DATABASE_URL: z.url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),

  // Env vars are strings, using coerce converts them for you
  PORT:      z.coerce.number().default(3000),
  DEBUG:     z.coerce.boolean().default(false),
  MAX_RETRY: z.coerce.number().int().min(1).max(10),
});

const env = EnvSchema.parse(process.env);
Inferred Type
type Env = {
  DATABASE_URL: string;
  NODE_ENV: 'development' | 'production' | 'test';
  PORT: number;      // coerced from "3000"
  DEBUG: boolean;    // coerced from "false"
  MAX_RETRY: number; // coerced + range-checked
}
Output
{
  "DATABASE_URL": "http://localhost:5432/mydb",
  "NODE_ENV": "development",
  "PORT": 3000,
  "DEBUG": true,
  "MAX_RETRY": 3
}