DEV Community

Cover image for Runtime Validation in TypeScript: Where Zod Ends and the Type System Begins
Gabriel Anhaia
Gabriel Anhaia

Posted on

Runtime Validation in TypeScript: Where Zod Ends and the Type System Begins


You wrote a function that takes a User. The type says email is a string and age is a number. The compiler is happy. Every call site is checked. Then the data arrives from a third-party webhook where age is the string "42" and email is missing, and your code reads user.age + 1 and produces "421", and a billing job runs on garbage for a week before anyone notices.

The compiler did nothing wrong. It checked the code you control. The webhook is not code you control. TypeScript types vanish at compile time, so the User annotation was a promise about a shape that nothing at runtime ever verified.

That gap is the whole job of runtime validation. The type system describes the program. A schema library like Zod describes the data crossing into the program. The skill worth learning is knowing exactly where one ends and the other begins.

Types are a compile-time fiction at the boundary

A type or interface is erased before your code runs. This compiles and ships:

function handle(req: Request) {
  const body = req.body as CreateUserBody;
  return createUser(body.email, body.age);
}
Enter fullscreen mode Exit fullscreen mode

The as is a lie you told the compiler. req.body is whatever the network delivered. The cast does not inspect it, coerce it, or reject it. It just renames unknown to CreateUserBody and tells the type checker to stop asking questions.

Inside your own modules, that fiction is fine. When createUser calls chargeCard, both ends are code you wrote and the compiler already proved they agree. No runtime check earns its keep there. The danger lives only where data enters: HTTP bodies, query params, env vars, JSON files, message queues, localStorage, the response from an API you don't own.

So the rule is narrow and worth memorizing: validate input and output, not internal calls.

Define the schema once, derive the type from it

The mistake people make first is writing the type and the schema as two separate things, then keeping them in sync by hand.

// Two sources of truth that will drift apart.
interface User {
  email: string;
  age: number;
}

const userSchema = z.object({
  email: z.string().email(),
  age: z.number().int(),
});
Enter fullscreen mode Exit fullscreen mode

Add a field to one and forget the other, and you are back to a cast that lies. The fix is to write the schema and let the type fall out of it with z.infer.

import { z } from "zod";

const userSchema = z.object({
  email: z.string().email(),
  age: z.number().int().nonnegative(),
});

type User = z.infer<typeof userSchema>;
// { email: string; age: number }
Enter fullscreen mode Exit fullscreen mode

Now there is one source of truth. The schema is the runtime check and the static type at the same time. Add age constraints, rename a field, make something optional, and the User type updates with it. The annotation and the validator can no longer disagree, because they are the same object viewed two ways.

Parse at the boundary, trust the type inside

Here is the shape that pays off. At the edge, you call parse. After that line, every value is a real User and the rest of the code is plain typed TypeScript with no defensive checks.

function handle(req: Request): Response {
  // The boundary. unknown in, User out.
  const user = userSchema.parse(req.body);

  // Past this line, user is a verified User.
  // No casts, no optional chaining, no "if it exists".
  return createUser(user.email, user.age);
}
Enter fullscreen mode Exit fullscreen mode

parse throws a ZodError if the data doesn't match. That is the behavior you want at an HTTP edge: reject the request, return a 400, never let bad data reach createUser. The function signature for createUser stays clean because it trusts its caller, and the caller earned that trust at the door.

When throwing is wrong (a webhook you must always 200, a config file with a fallback), use safeParse, which returns a discriminated union instead.

const result = userSchema.safeParse(req.body);

if (!result.success) {
  logger.warn("bad payload", result.error.issues);
  return badRequest(result.error);
}

const user = result.data; // typed User
Enter fullscreen mode Exit fullscreen mode

The result.success check narrows the union. In the false branch you get result.error; in the true branch you get result.data typed as User. The type system carries the validation outcome forward, so you cannot read .data without first proving the parse succeeded.

Validate output too, not only input

Input validation is the obvious half. The half people skip is the response from an API they don't own. A third-party endpoint changes a field from a number to a string in a minor release, and your code that "knew" the shape inherits the bug silently.

const pricingSchema = z.object({
  currency: z.string().length(3),
  amountCents: z.number().int(),
});

async function getPricing(): Promise<z.infer<typeof pricingSchema>> {
  const res = await fetch("https://api.vendor.example/pricing");
  const json: unknown = await res.json();
  // Their data is not your data. Check it.
  return pricingSchema.parse(json);
}
Enter fullscreen mode Exit fullscreen mode

The return type is derived from the schema, so callers get a fully typed value, and the value was actually verified against the contract you expect. When the vendor breaks the contract, you find out at the parse line with a precise error, not three layers deep where amountCents got concatenated into a string.

Coercion is part of the boundary's job

Boundaries are messy. Query strings are all strings. Env vars are all strings. Form fields are all strings. The schema is the right place to coerce, because coercion is a runtime concern and the schema is your only runtime-aware layer.

const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().max(100).default(20),
  q: z.string().trim().optional(),
});

type Query = z.infer<typeof querySchema>;
// { page: number; limit: number; q?: string }
Enter fullscreen mode Exit fullscreen mode

z.coerce.number() accepts the string "3" and hands back the number 3. The inferred Query type already reflects the post-coercion shape: page is number, not string. The boundary takes ugly external input and produces the clean internal type the rest of your code expects. Defaults fill the gaps so downstream code never branches on "did they send a page number."

The same applies to environment config, which is the boundary every app forgets until production:

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

export const env = envSchema.parse(process.env);
Enter fullscreen mode Exit fullscreen mode

Parse once at startup. If DATABASE_URL is missing or NODE_ENV is a typo, the process fails immediately with a clear message instead of hours later with a connection error nobody can trace.

Where to draw the line

The discipline is one sentence: schemas guard the perimeter, types guard the interior.

A function deep in your domain logic that takes a User does not need to re-validate it. The User was parsed at the HTTP handler, and inside the program the type system already guarantees the shape. Re-parsing on every internal call is wasted work and noise — it suggests you don't trust your own boundary, which means the boundary is in the wrong place.

So the map is:

  • Validate: HTTP request bodies, query params, env vars, file reads, queue messages, third-party API responses, anything from JSON.parse, anything cast from unknown.
  • Trust: every function call between modules you wrote and the compiler already checked.

Get the perimeter right and the interior stays cheap. One parse at the door, z.infer to derive the type, and the rest of the codebase is ordinary typed TypeScript that never asks whether the data is real, because the boundary already answered.

The bug from the opening — user.age + 1 producing "421" — cannot happen once age passes through z.number() at the edge. The cast that renamed unknown to User is replaced by a parse that earns the name.


If this was useful

Schema-first validation is one of the patterns that shows up the moment you ship TypeScript past a toy project — alongside the build, tooling, and dual-publish concerns that the type system itself never touches. If the boundary-vs-interior split above clicked, TypeScript in Production takes the same idea through monorepos, library authoring, and the runtime-validation patterns you reach for at scale.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)