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(); usez.coerce.number()to parse string query params.z.boolean()— orz.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])orz.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 surprises —
z.coerce.number()turns""into0; 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.issueswith 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
safeParseis 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
safeParsein HTTP handlers; reserveparsefor 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
- TypeScript fundamentals explained — static types, narrowing, and strict compiler options Zod complements
- Hono fundamentals explained — lightweight HTTP framework with first-class Zod validator middleware
- NestJS fundamentals explained — DTO validation pipes and modular API structure
- Prisma fundamentals explained — database modeling; validate before writes, not after