All posts
JavaScript & TypeScript··9 min read

Zod in Production: Validating at the Edges of Your App

Types vanish at runtime, and external data lies. How I use Zod to validate at every boundary, env vars, API input, webhooks, so bad data fails loudly and early.

By

On this page

A staging deploy once went out with STRIPE_WEBHOOK_SECRET left undefined. TypeScript was perfectly happy: process.env.STRIPE_WEBHOOK_SECRET has type string | undefined, and somewhere downstream a ! non-null assertion had quietly papered over the undefined. The app booted clean. It ran for three hours. Then the first webhook arrived, signature verification threw deep inside a request handler, the error got swallowed by a retry wrapper, and we lost a batch of subscription events that Stripe stopped retrying after 72 hours. The fix was eight lines of Zod that would have crashed the process on boot instead.

That is the whole argument for runtime validation, and it is worth internalizing before reaching for any library: your types are a compile-time fiction. tsc erases every annotation, interface, and generic before the code ever runs. At runtime there is no type system, only values. And every value that crosses a boundary into your program, a request body, an env var, a webhook payload, a row from a third-party API, is genuinely unknown. The User type you so carefully wrote is a promise you made to yourself, not a guarantee the network honored.

The boundary is the only place that matters

Here is the mental model I have settled on after years of this: trust the inside of your program, validate the edges. Inside a function that received an already-validated Order, asserting and re-checking is noise. At the moment data enters, from req.json(), from process.env, from fetch().then(r => r.json()), you have an any or unknown wearing a costume. That is where validation earns its keep.

Zod (I'm writing against Zod 4, current as of 2026) inverts the usual relationship between types and validators. Instead of writing a TypeScript type and a separate runtime check that can drift out of sync, you write the schema once and derive the type from it:

import { z } from "zod";
 
const User = z.object({
  id: z.uuid(),
  email: z.email(),
  role: z.enum(["admin", "member", "viewer"]),
  createdAt: z.coerce.date(),
});
 
type User = z.infer<typeof User>;
// { id: string; email: string; role: "admin" | "member" | "viewer"; createdAt: Date }

z.infer makes the schema the single source of truth. Change the schema and the type updates automatically. There is no way for the runtime check and the static type to disagree, which is the entire bug class this eliminates.

Parse at startup so misconfig fails on boot

The env story is the highest-leverage place to start, because config errors are the cheapest to catch and the most expensive to discover in production. Parse process.env once, at module load, and export a typed object. If anything is missing or malformed, the process dies before it serves a single request.

// src/env.ts
import { z } from "zod";
 
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.url().startsWith("postgres://"),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
 
const parsed = EnvSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error("❌ Invalid environment variables:");
  console.error(z.prettifyError(parsed.error));
  process.exit(1);
}
 
export const env = parsed.data;

Three things are doing real work here. z.coerce.number() turns the string "3000" (env vars are always strings) into a number. The .default() calls mean local dev works with a half-empty .env. And process.exit(1) is the point: a bad config now produces a loud, immediate, readable crash with z.prettifyError listing exactly which keys failed, instead of a string | undefined time bomb three hours into the deploy. Import env from this module everywhere instead of touching process.env directly, and env.PORT is a guaranteed number.

safeParse vs parse: pick on purpose

Zod gives you two entry points and the choice is not stylistic.

  • parse(data) returns the typed value or throws a ZodError. Use it where a failure is a programmer bug or a genuinely exceptional state, env parsing, internal invariants, places where throwing into your global error handler is the right outcome.
  • safeParse(data) returns a discriminated result { success: true, data } or { success: false, error } and never throws. Use it at every user-facing boundary, request bodies, query strings, form submissions, where invalid input is expected, routine, and needs to become a 400 rather than a 500.

The rule I follow: if invalid data should produce a normal HTTP response, safeParse. If invalid data means the program is broken, parse.

Validating an API route body

Here is a route handler (Hono, but the shape is identical in Express or a Next.js route handler) that validates the body and gets a fully typed result:

import { Hono } from "hono";
import { z } from "zod";
 
const CreateOrder = z.object({
  items: z
    .array(z.object({ sku: z.string(), qty: z.number().int().positive() }))
    .min(1),
  couponCode: z.string().trim().toUpperCase().optional(),
  shippingMethod: z.enum(["standard", "express"]).default("standard"),
});
 
const app = new Hono();
 
app.post("/orders", async (c) => {
  const result = CreateOrder.safeParse(await c.req.json());
 
  if (!result.success) {
    return c.json({ errors: z.flattenError(result.error) }, 400);
  }
 
  // result.data is fully typed: items, couponCode?, shippingMethod
  const order = await createOrder(result.data);
  return c.json(order, 201);
});

Note the transforms baked into the schema: .trim().toUpperCase() on couponCode means by the time you read result.data.couponCode, it is already normalized. Validation and normalization happen in the same pass, so no couponCode.trim().toUpperCase() scattered through business logic. z.flattenError produces a { formErrors, fieldErrors } shape that maps cleanly onto form fields on the client.

For genuinely cross-field rules, lean on superRefine, which can attach multiple, precisely-pathed errors:

const DateRange = z
  .object({ start: z.coerce.date(), end: z.coerce.date() })
  .superRefine((val, ctx) => {
    if (val.end <= val.start) {
      ctx.addIssue({
        code: "custom",
        message: "end must be after start",
        path: ["end"],
      });
    }
  });

Webhooks: where external data lies the most

Webhooks are the boundary I trust the least. The payload comes from a third party, on their schedule, in whatever shape their latest API version emits, and you find out about breaking changes when verification or parsing throws in production. A discriminatedUnion keyed on the event type is the right tool, because it lets Zod narrow the payload precisely and gives you exhaustive switch handling:

import { z } from "zod";
 
const Subscription = z.object({
  id: z.string(),
  status: z.enum(["active", "past_due", "canceled"]),
  currentPeriodEnd: z.coerce.date(),
});
 
const WebhookEvent = z.discriminatedUnion("type", [
  z.object({ type: z.literal("subscription.created"), data: Subscription }),
  z.object({ type: z.literal("subscription.updated"), data: Subscription }),
  z.object({
    type: z.literal("subscription.deleted"),
    data: z.object({ id: z.string() }),
  }),
]);
 
function handleWebhook(raw: unknown) {
  const event = WebhookEvent.parse(raw); // throws → caught by error middleware
 
  switch (event.type) {
    case "subscription.created":
    case "subscription.updated":
      return upsertSubscription(event.data); // data narrowed to Subscription
    case "subscription.deleted":
      return removeSubscription(event.data.id); // data narrowed to { id }
    default:
      return event satisfies never; // compile error if a case is missed
  }
}

The satisfies never in the default branch is the quiet hero. Add a new event variant to the union and forget to handle it, and the build breaks. Your validation schema and your handler logic can no longer drift apart silently. I use parse here deliberately: a webhook that does not match any known shape is an exceptional condition I want surfaced and retried, not silently 200'd.

Compose, don't repeat

Schemas are values, so reuse them like any other value. .extend(), .pick(), .omit(), .partial(), and .merge() let one canonical schema spawn the variants you need for create, update, and public-facing responses:

const BaseProduct = z.object({
  id: z.uuid(),
  name: z.string().min(1).max(200),
  priceCents: z.number().int().nonnegative(),
  internalCost: z.number().int().nonnegative(),
});
 
const CreateProduct = BaseProduct.omit({ id: true });
const UpdateProduct = CreateProduct.partial();
const PublicProduct = BaseProduct.omit({ internalCost: true }); // never leak cost

This is also a security control. PublicProduct omitting internalCost means a response serialized through that schema physically cannot include the field, which is a far stronger guarantee than remembering to delete it by hand. It maps directly onto the kind of excessive data exposure called out in the OWASP API Security Top 10.

Where NOT to validate, and what it costs

Validation is not free and not always wise. Some honest guidance:

BoundaryValidate?Why
Env vars at startupAlwaysOne-time cost, catches misconfig on boot
API request bodiesAlwaysUntrusted user input, OWASP territory
Webhook payloadsAlwaysThird-party shape can change without notice
3rd-party API responsesUsuallyThey lie, version, and break
Internal function argsNeverAlready validated upstream; TypeScript suffices
Trusted DB rowsRarelySchema is enforced by the DB; validate only on migration risk
Hot loops (per-element, high throughput)Measure firstParsing has real cost at scale

That last row matters. Zod parsing is fast enough to be invisible at request granularity, single-digit microseconds for a modest object, but it is not free in a tight loop processing millions of records. Validate the batch at ingest, not each element on every iteration. And do not validate data you already validated; re-parsing a value you produced internally is pure overhead and a sign the boundary is in the wrong place. If you have a genuinely hot path, parse once at the edge and pass the typed value inward.

One more practical note for high-throughput services: Zod compiles its checks lazily, so build schemas once at module scope, never inside a handler. Constructing z.object({...}) on every request reallocates the whole validator and shows up in a flame graph faster than you'd expect.

A checklist for the edges

When I audit a service for runtime safety, I walk the boundaries in this order:

  1. Env: is process.env parsed once at startup with safeParse and process.exit(1) on failure? Does the app import a typed env, never raw process.env?
  2. Inbound HTTP: does every route safeParse its body, query, and params, and return z.flattenError as a 400?
  3. Webhooks: is every payload a discriminatedUnion with parse, exhaustive switch, and a satisfies never guard?
  4. Outbound integrations: are third-party responses parsed before you trust them?
  5. Types: is every boundary type produced by z.infer, with zero hand-written duplicate types that can drift?
  6. Performance: are schemas defined at module scope, and is validation absent from hot inner loops?

Get those six right and the entire class of "the data wasn't what TypeScript said it was" bugs disappears from your production logs. Types describe your intent; Zod enforces it at the only moment that counts, when real data shows up. Put the check at the door, make it loud, and let bad data fail on boot instead of three hours into a deploy.

Further reading

  • Zod documentation — zod.dev
  • OWASP API Security Top 10 — owasp.org
  • MDN, "JSON" and the Response.json() method — developer.mozilla.org