Guide

Zod fundamentals explained

A payment API accepts { "amount": "12.50" } as a string because the mobile client serialized a number wrong. TypeScript never saw it — types vanish at runtime. Zod is a TypeScript-first schema library that parses unknown input, coerces when appropriate, and returns either typed data or a structured error list. It sits at the boundary where HTTP bodies, environment variables, and form fields enter your application logic. Teams pair Zod with TypeScript, Hono, NestJS, and tRPC so one schema defines validation rules and inferred types. This guide covers schema primitives, parse vs safeParse, refinements and transforms, z.infer, error formatting, composition patterns, a Harbor Payments fee calculator API worked example, a validation-library decision table, common pitfalls, and a production checklist.

Why runtime validation exists

Compile-time types protect code you control. They do not protect:

  • HTTP request bodies — arbitrary JSON from browsers, mobile apps, and webhooks.
  • Environment variables — strings that may be missing, malformed, or injected by misconfigured deploy scripts.
  • Database JSON columns — legacy rows written before a schema change.
  • Third-party API responses — vendors change field names without a major version bump.

Manual if checks sprawl and drift from TypeScript interfaces. Zod keeps a single source of truth: define a schema once, infer the type with z.infer<typeof schema>, and validate at every trust boundary. Invalid input fails before it reaches business logic or SQL.

Zod vs TypeScript alone

TypeScript answers “what shape does this variable claim to have?” Zod answers “does this untrusted value actually match that shape?” Use both: interfaces for internal function contracts, Zod schemas for external input.

Schema primitives

Every Zod schema is built from composable primitives. The most common:

  • z.string() — optional .min(), .max(), .email(), .uuid(), .regex().
  • z.number().int(), .positive(), .finite(); use z.coerce.number() to parse string query params.
  • z.boolean() — or z.coerce.boolean() for form strings like "true".
  • z.object({ ... }) — fixed keys; unknown keys stripped by default (.strict() to reject extras).
  • z.array(itemSchema).min(1), .max(100) for bounded lists.
  • z.enum(["draft", "published"]) — string unions with runtime enforcement.
  • z.union([a, b]) or z.discriminatedUnion("type", [...]) — tagged variants (webhook event types).

Optional, nullable, and default

.optional() allows undefined. .nullable() allows null. .default("value") supplies a fallback when input is undefined. JSON APIs often use null for absent fields; web forms send empty strings. Decide per field whether "" should become undefined via .transform() or fail validation.

Records and nested objects

z.record(z.string()) validates arbitrary string-keyed maps (metadata bags). Nest objects for DTOs: z.object({ user: UserSchema, items: z.array(LineItemSchema) }). Extract shared pieces with const AddressSchema = z.object({ ... }) and reuse across create/update schemas via .pick(), .omit(), or .partial() for PATCH endpoints.

parse, safeParse, and error handling

parse throws

schema.parse(input) returns typed output or throws ZodError. Fine for scripts and tests where failure should crash immediately. Risky in HTTP handlers unless you wrap in try/catch.

safeParse returns a result object

const result = schema.safeParse(input) yields { success: true, data } or { success: false, error }. Map error.flatten() or error.format() to 400 responses with field-level messages. Never send raw ZodError stacks to clients in production — they expose internal field names attackers probe.

parseAsync for async refinements

Database uniqueness checks and remote lookups belong in .refine(async (val) => ...) or .superRefine(). Use safeParseAsync in handlers. Keep async refinements out of hot paths when a synchronous rule suffices.

Refinements, transforms, and branded types

refine and superRefine

.refine((data) => data.endDate > data.startDate, { message: "End must be after start" }) adds cross-field rules object schemas cannot express with primitives alone. .superRefine((val, ctx) => { ctx.addIssue({ code: "custom", path: ["field"], message: "..." }) }) attaches multiple issues or conditional errors in one pass.

transform and preprocess

.transform((s) => s.trim().toLowerCase()) normalizes after validation. z.preprocess() runs before the base schema — useful for coercing "" to undefined on optional form fields. Be careful: transforms change the inferred output type; document them so consumers know the post-parse shape differs from the wire format.

Branded types for domain IDs

const UserId = z.string().uuid().brand<"UserId">() creates a nominal type at the TypeScript layer so you cannot pass a random string where a UserId is required. Helpful in large codebases with many string IDs.

Type inference with z.infer

Duplicating an interface and a Joi schema was the old pattern. With Zod:

const CreateOrderSchema = z.object({
  sku: z.string().min(1).max(64),
  quantity: z.number().int().positive(),
  currency: z.enum(["USD", "EUR", "GBP"]),
});
type CreateOrder = z.infer<typeof CreateOrderSchema>;

CreateOrder updates automatically when the schema changes. Export schemas from a schemas/ module; import types via z.infer in services and handlers. For input vs output types, use z.input<typeof schema> when transforms make them differ.

Composing partial and extended schemas

UpdateOrderSchema = CreateOrderSchema.partial() for PATCH. CreateOrderSchema.extend({ id: z.string().uuid() }) for responses. CreateOrderSchema.merge(MetadataSchema) for cross-cutting fields. Avoid deep cloning schemas per request — define once at module scope.

Integration patterns in real stacks

Hono and Express middleware

In Hono, validate with zValidator("json", schema) from @hono/zod-validator before the handler runs. Invalid bodies return 400 without touching your route logic. The same schema can validate OpenAPI examples if you generate specs from Zod via zod-openapi or @asteasolutions/zod-to-openapi.

Environment variables

Boot-time validation prevents half-configured deploys:

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]),
});
export const env = EnvSchema.parse(process.env);

Fail fast at process start rather than when the first DB connection attempt throws an opaque error.

Forms and client-side reuse

Share schemas between client and server in monorepos (see monorepo architecture). Libraries like @hookform/resolvers/zod wire Zod into React Hook Form so the browser shows the same messages the API returns. Client validation is UX; server validation is security — never skip the server pass.

Worked example: Harbor Payments fee calculator API

Harbor Payments exposes POST /v1/fees/quote for merchants estimating card-processing cost. The handler must reject malformed amounts, unknown currencies, and impossible basis-point values before hitting the pricing engine.

const FeeQuoteSchema = z.object({
  amountCents: z.number().int().min(1).max(99_999_999),
  currency: z.enum(["USD", "EUR", "GBP"]),
  cardPresent: z.boolean(),
  interchangeBps: z.number().int().min(0).max(500).optional(),
  merchantId: z.string().uuid(),
}).refine(
  (d) => d.currency !== "GBP" || d.amountCents >= 30,
  { message: "GBP minimum charge is 30 pence", path: ["amountCents"] }
);

app.post("/v1/fees/quote", async (c) => {
  const body = await c.req.json();
  const parsed = FeeQuoteSchema.safeParse(body);
  if (!parsed.success) {
    return c.json({ errors: parsed.error.flatten().fieldErrors }, 400);
  }
  const quote = await pricingEngine.quote(parsed.data);
  return c.json(quote);
});

The same FeeQuoteSchema types the pricing engine input. Integration tests import the schema and property-test random valid tuples. A separate FeeQuoteResponseSchema validates outbound JSON from the engine so refactors cannot return NaN fees to clients.

Validation library decision table

Need Prefer Why
TypeScript-first API with z.infer Zod Native TS inference; largest ecosystem of framework adapters.
Smallest bundle on edge workers Valibot or ArkType Tree-shakable; pay only for validators you import.
Legacy JavaScript codebase, no TS Joi or Yup Mature; Yup pairs with Formik; Joi common in Express shops.
OpenAPI spec is source of truth openapi-typescript + generated types, or Zod-to-OpenAPI Avoid duplicating schema in three places; pick one direction.
JSON Schema interop (config tools, codegen) zod-to-json-schema or Ajv Zod for app code; emit JSON Schema for external consumers.
Heavy custom async DB checks per field Zod superRefine + service layer Keep schemas readable; move multi-table rules to domain services if refinements grow unwieldy.

Common pitfalls

  • Validating too late — parsing inside the service after logging raw PII; validate at the HTTP boundary first.
  • Coercion surprisesz.coerce.number() turns "" into 0; use explicit preprocessing for forms.
  • Strict vs strip — default object parsing silently drops unknown keys; use .strict() on public APIs to catch client typos.
  • Schema per request — rebuilding schemas inside handlers allocates unnecessarily; define at module scope.
  • Leaking Zod internals — returning full error.issues with internal paths to browsers; flatten and sanitize.
  • Duplicate schemas — separate create/update copies that drift; use .partial() and shared base objects.
  • Trusting client-side only — attackers bypass the browser; server safeParse is mandatory.

Production checklist

  • Define schemas in a shared module; export types via z.infer.
  • Validate every inbound HTTP body, query, and param object with safeParse.
  • Parse and validate environment variables at application boot.
  • Use .strict() on public API object schemas unless backward compatibility requires strip.
  • Map validation errors to consistent 400 JSON with field keys, not stack traces.
  • Add unit tests for edge cases: empty strings, null, oversized arrays, unicode.
  • Validate third-party webhook payloads before enqueueing background jobs.
  • Consider response schemas for critical outbound contracts (payments, auth tokens).
  • Document coercion and transform behavior in API docs.
  • Benchmark bundle size if shipping Zod to edge; evaluate Valibot for client-only bundles.
  • Version schemas when breaking wire formats; support both during migration windows.
  • Pair with Express or Hono rate limiting so validation failures do not become DoS amplifiers.

Key takeaways

  • TypeScript types do not validate runtime input; Zod closes that gap at trust boundaries.
  • One schema drives validation rules and inferred types via z.infer.
  • Prefer safeParse in HTTP handlers; reserve parse for tests and boot-time env checks.
  • Refinements handle cross-field rules; transforms normalize wire quirks before business logic.
  • Client-side Zod improves UX; server-side Zod is the security gate.

Related reading