Zod Runtime Validation in TypeScript Apps

Engineering
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

Malformed CMS payload crashed checkout until Zod at API boundary failed in dev with field path not user session.

Schema-first boundaries

External JSON is untrusted until validated. Define Zod schema once; infer TypeScript with z.infer:

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(120),
  role: z.enum(["member", "admin"]).default("member"),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;

Hand-maintained interfaces drift from runtime checks — schema-first keeps them aligned.

safeParse at HTTP handlers

export async function createUser(req: Request) {
  const parsed = CreateUserSchema.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
  }
  return userService.create(parsed.data);
}

Return field paths for form UX — not generic 400 strings.

Environment validation at boot

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(["development", "production", "test"]),
});
export const env = EnvSchema.parse(process.env);

Fail fast on misconfiguration — not on first request in production.

Transform and refine

const PriceSchema = z.string().transform((s) => parseFloat(s)).refine((n) => n >= 0);

Coerce query params and form strings at the boundary.

Discriminated unions

const ApiResponseSchema = z.discriminatedUnion("ok", [
  z.object({ ok: z.literal(true), data: UserSchema }),
  z.object({ ok: z.literal(false), error: z.string() }),
]);

Narrowing works in TypeScript after parse — same as hand-written unions but validated.

Client forms with react-hook-form

const form = useForm<CreateUserInput>({
  resolver: zodResolver(CreateUserSchema),
});

One schema powers client and server when you share the module.

Common mistakes

When to prioritize

When external json needs validation at system boundaries.

Anti-pattern to avoid

Duplicate interface plus Zod schema drifting apart — use z.infer from single schema

Branded types and nominal safety

When stringly-typed IDs cross layers, use Zod transforms to brand values at the boundary:

const UserIdSchema = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;

function getUser(id: UserId) { /* cannot pass OrderId accidentally */ }

Branding catches swapped identifiers at compile time after a single parse — cheaper than debugging cross-tenant data leaks in production.

Preprocess for messy query strings

Query params arrive as strings. Preprocess before validation instead of casting:

const PaginationSchema = z.object({
  page: z.preprocess((v) => Number(v), z.number().int().min(1).default(1)),
  limit: z.preprocess((v) => Number(v), z.number().int().min(1).max(100).default(20)),
});

Coercion at the boundary keeps handlers free of parseInt scattered across routes.

superRefine for cross-field rules

Password confirmation, date ranges, and conditional required fields belong in superRefine:

const SignupSchema = z.object({
  password: z.string().min(12),
  confirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({ code: "custom", path: ["confirm"], message: "Passwords must match" });
  }
});

Field-level paths map directly to form error display — one schema powers client and server when shared.

Versioning API schemas

When loosening validation breaks mobile clients on old builds, version schemas explicitly:

const CreateOrderV2Schema = CreateOrderV1Schema.extend({ giftMessage: z.string().max(200).optional() });

Route handlers select schema by API version header. Never silently widen required fields without a version bump.

Performance on hot paths

Parsing large JSON with deep nesting on every request adds latency. Validate shape once at ingress; trust internal calls after. For high-QPS read endpoints, consider compiled parsers or selective validation of mutable fields only on PATCH.

Cache parsed env at boot — do not re-parse process.env per request. For webhooks, validate signature before schema to fail fast on junk traffic.

Testing schemas as contracts

Export schemas from a shared package consumed by API and frontend. Snapshot tests on .safeParse fixtures for golden payloads and known-bad CMS exports. Property-based tests on optional field combinations catch regressions when editors add new block types.

Observability for validation failures

Log validation failure rates by route and field path — spikes on items.0.sku often mean CMS schema changed before frontend deployed. Alert when 400 rate doubles week-over-week on checkout POST.

Closing checklist

Malformed CMS payload crashed checkout until Zod at API boundary failed in dev with field path — not user session. Schema-first validation turns mysterious production crashes into actionable 400 responses during QA.

Shared package layout

Publish schemas from @acme/schemas consumed by API, workers, and frontend. Version the package independently from app deploys — CMS schema changes bump schema package before UI catches up.

// packages/schemas/src/order.ts
export const OrderSchema = z.object({ /* ... */ });
export type Order = z.infer<typeof OrderSchema>;

Tree-shake unused schemas in frontend bundles — import only checkout schemas on checkout route, not entire catalog.

Webhook and queue payloads

Message queues deliver JSON bytes — validate at consumer entry identically to HTTP. Poison messages land in DLQ with validation error attached for replay after fix. Never assume broker authenticated means payload trustworthy.

Gradual strictness

Tighten schemas in phases: log-only mode records would-be failures without rejecting, then enforce after false-positive rate near zero. Sudden strictness on legacy CMS exports causes production brownouts.

Integration with OpenAPI

Generate Zod from OpenAPI or vice versa — pick one direction as source of truth. Drift between OpenAPI spec and Zod in repo causes mobile client/server disagreements visible only in production.

Contract testing with Pact and Zod

Consumer-driven contract tests export expected JSON shapes — validate producer responses against the same Zod schema used in production handlers. Drift fails CI before mobile team ships incompatible payload.

Rate limiting validation errors

Spike in 400 responses from one route after CMS deploy — dashboard validation error paths by hour. Correlation with CMS publish events cuts mean time to resolution from hours to minutes.

Async schema refinement

For streaming parsers, validate chunks with smaller schemas before assembling full document — fail fast on malformed first chunk instead of after full upload completes.

Additional context (1)

Malformed CMS payload crashed checkout until Zod at API boundary failed in dev with field path not user session. Document which routes or tenants you changed first, and keep rollback paths in the PR description before promoting beyond canary traffic.

Additional context (2)

Malformed CMS payload crashed checkout until Zod at API boundary failed in dev with field path not user session. Document which routes or tenants you changed first, and keep rollback paths in the PR description before promoting beyond canary traffic.

Schemas at the trust boundary

Zod belongs where data enters: HTTP handlers, queue consumers, env loaders, and webhooks. Infer types from schemas so runtime and compile-time cannot drift. Strip unknown keys on untrusted input.

Share schemas via a package. Parse once on hot paths and pass branded values inward. Log failures with stable codes — never raw payloads with secrets. Version schemas like APIs; removals need dual-read windows.

Operations note 1 for typescript zod runtime validation

Name the owner, dashboard, and rollback for typescript zod runtime validation. Add one automated check for the failure path. Prefer progressive delivery. Require a compatibility note when typescript zod runtime validation changes cross team boundaries. Rehearse rollback once in staging.

Operations note 2 for typescript zod runtime validation

Name the owner, dashboard, and rollback for typescript zod runtime validation. Add one automated check for the failure path. Prefer progressive delivery. Require a compatibility note when typescript zod runtime validation changes cross team boundaries. Rehearse rollback once in staging.

Frequently asked questions

Schema first or TypeScript interface first?

Define the Zod schema first and infer the TypeScript type with z.infer. Hand-maintained interfaces drift from runtime checks within weeks — schema-first keeps compile-time and runtime aligned from one source.

Where should Zod validation run?

At system boundaries: HTTP handlers, webhook receivers, environment boot, and form submission. Avoid validating every internal function call — cost adds up and duplicates trust already established inside your process.

How should validation errors reach users?

Use safeParse and return structured field errors — flatten() or format() for forms, path arrays for APIs. Generic 400 strings force support tickets when the fix is a missing postal code.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →