Breaking
Validate Node.js API requests with Zod

Validate Node.js API requests with Zod

How this was written

Drafted in plain Markdown by Ethan Laurent and edited against current Node.js, framework and tooling docs. Every command, code block and benchmark in this article was run on Node 24 LTS before publish; if a step does not work on your machine the post is wrong, not you — email and I will fix it.

AI is used as a research and outline assistant only — never as a single-source author. Full editorial policy: About / How nodewire is written.

A malformed POST /orders body took our checkout down at 2am last spring. The handler trusted req.body.quantity was a number, passed it straight into a Prisma create, and Postgres choked on a string where it wanted an integer — except the failure surfaced three calls deep, inside a transaction, with a stack trace that pointed at the ORM and not at the lie that started it. The on-call engineer spent forty minutes proving the bug lived in the request, not the database. Zod validation in Node.js would have killed that request at the door with a 400 and a one-line message, and we’d all have slept.

Validation pattern

Zod validation belongs at the API edge: parse body, query, and params with safeParse, return a clean 400 on invalid input, and infer TypeScript types from the same schema so your handlers stay honest. It catches runtime input problems that TypeScript cannot see after JSON crosses the network.

That is the whole pitch. Validation at the API edge is not a nice-to-have; it is the membrane between the open internet and code that assumes its inputs are sane. Zod gives you that membrane with one source of truth — a schema that both checks data at runtime and hands TypeScript the static type for free. This guide walks through the Zod 4 API as it stands in June 2026, building up to a reusable Express middleware you can drop into any route.

The problem: TypeScript types vanish at runtime

Here is the trap people fall into. You define an interface, annotate req.body as that interface, and TypeScript goes green. But interfaces are erased at compile time. At runtime req.body is whatever JSON the client sent, and the client can send anything — a number where you wanted a string, a missing field, an object where you wanted an array. Your type annotation is a sticky note that says “trust me,” and the internet does not care about your sticky note.

Zod closes the gap because a Zod schema is a real value that exists at runtime. Install it:

bash
npm install zod

Zod 4 is the current major line and ships under the same zod package name. A first schema:

TypeScript
import { z } from "zod";

const CreateUser = z.object({
  email: z.email(),
  name: z.string().min(1).max(80),
  age: z.number().int().positive().optional(),
});

Two things to flag for anyone coming from Zod 3. String formats moved to top-level functions: it is z.email() now, not z.string().email(). The old chained form still works but is deprecated, and the top-level functions get the better-maintained validators. Second, .optional() means the key may be absent — it does not mean null. If your client sends null, use .nullable(), or .nullish() for either.

parse vs safeParse: don’t let validation throw at the edge

.parse() and .safeParse() both run the schema. The difference is how they fail, and at an API boundary that difference matters.

.parse(data) returns the validated value or throws a ZodError:

TypeScript
const user = CreateUser.parse(req.body); // throws on bad input

.safeParse(data) never throws. It returns a discriminated union you branch on:

TypeScript
const result = CreateUser.safeParse(req.body);

if (!result.success) {
  // result.error is a ZodError
  return res.status(400).json({ error: "Invalid request" });
}

const user = result.data; // fully typed, guaranteed valid

Reach for safeParse at the edge. A thrown exception in a handler is something you then have to catch, route to error-handling middleware, and translate back into a 400 — and if you forget the catch, an async throw becomes an unhandled rejection that can take the process down. (If you do lean on throws elsewhere, wire up async error handling in Express so nothing escapes uncaught.) safeParse keeps the failure as a plain value you handle inline, where you already hold the response object. parse is fine for trusted internal data — config you control, a test fixture — where a throw genuinely means a bug, not a hostile client.

There is also .parseAsync() / .safeParseAsync(), which you need only when a schema contains an async .refine() (say, a uniqueness check that hits the database).

One source of truth: infer the type with z.infer

Define the shape once, derive the TypeScript type from it. Do not write the interface twice — the second copy is the one that drifts.

TypeScript
type CreateUserInput = z.infer<typeof CreateUser>;
// { email: string; name: string; age?: number }

Now CreateUserInput is generated from the schema. Add a field, the type updates. Make age required, the type updates. This is the part of Zod that pays rent forever: your runtime check and your compile-time type can never disagree, because there is only one of them. For the mechanics of how infer reads a schema’s type, the TypeScript handbook on inference covers the conditional-type machinery underneath it.

A small but real gotcha: when a schema has .transform() or .default(), the input type and output type differ. z.infer gives you the output type (post-parse). For the pre-parse shape use z.input<typeof Schema>. Most route code wants the output, so z.infer is the default reach.

The reusable validate() middleware for body, query, and params

You do not want a safeParse block copy-pasted into thirty route handlers. Factor it into one middleware that takes a schema and validates the part of the request you point it at.

TypeScript
import { Request, Response, NextFunction } from "express";
import { z, ZodType } from "zod";

type Target = "body" | "query" | "params";

export function validate(schema: ZodType, target: Target = "body") {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      return res.status(400).json({
        error: "Validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
    }

    // overwrite with the parsed (coerced, defaulted) value
    req[target] = result.data;
    next();
  };
}

Wire it onto routes:

TypeScript
app.post("/users", validate(CreateUser), (req, res) => {
  // req.body is validated here; cast to your inferred type
  const input = req.body as z.infer<typeof CreateUser>;
  res.status(201).json({ id: "usr_123", ...input });
});

const UserParams = z.object({ id: z.uuid() });
app.get("/users/:id", validate(UserParams, "params"), (req, res) => {
  res.json({ id: (req.params as { id: string }).id });
});

One honest caveat for Express specifically. In Express 5, req.query is a getter and is not writable, so assigning req.query = result.data throws. Validate query into a separate property — res.locals.query or a custom field — instead of overwriting in place. For body and params the in-place overwrite is fine and is what makes coercion (next section) actually reach your handler.

Clean 400s: format Zod errors for clients, not for yourself

A raw ZodError is verbose and shaped for debugging, not for an API consumer. Zod 4 gives you two formatters and you pick based on schema depth.

z.flattenError(error) is the right tool for flat, one-level schemas — most request bodies. It returns formErrors (top-level issues) and fieldErrors (keyed by field name):

TypeScript
const result = CreateUser.safeParse({ email: "nope", name: "" });
if (!result.success) {
  console.log(z.flattenError(result.error));
  // {
  //   formErrors: [],
  //   fieldErrors: {
  //     email: ["Invalid email address"],
  //     name: ["Too small: expected string to have >=1 characters"],
  //   },
  // }
}

For nested schemas — an object with sub-objects or arrays — z.flattenError flattens too aggressively and you lose the path. Use z.treeifyError(error) instead; it returns a tree that mirrors the schema, with properties for object keys and items for array positions, each carrying its own errors array. There is also z.prettifyError(error), which produces a human-readable multi-line string — handy for log output or a CLI, less so for a JSON API.

Customize messages by passing { error: "..." } — Zod 4 unified the old message/invalid_type_error parameters into one error key:

TypeScript
const CreateUser = z.object({
  email: z.email({ error: "Enter a valid email." }),
  name: z.string().min(1, { error: "Name is required." }),
});

Whatever shape you settle on, keep it consistent across every endpoint. A client that gets { details: { fieldErrors: {...} } } from one route and a bare string from another has to special-case your API, and they will resent you for it.

Transforms and coercion: query strings are always strings

Query parameters arrive as strings. Always. ?page=2&limit=50 gives you page: "2", and if you hand "2" to code expecting a number you get string concatenation bugs or a failed comparison. Validate and convert in one pass with z.coerce:

TypeScript
const ListQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["asc", "desc"]).default("asc"),
});

// ?page=3 -> { page: 3, limit: 20, sort: "asc" }

z.coerce.number() runs Number(input) before validating, so "3" becomes 3 and then the .int().min(1) checks run against the real number. The .default() calls fill in missing params. This is exactly why the middleware overwrites req[target] with result.data — without that write, your handler would still see the raw string version.

For reshaping rather than converting, .transform() runs an arbitrary function on the parsed value:

TypeScript
const Slugify = z.string().transform((s) => s.trim().toLowerCase().replace(/s+/g, "-"));
Slugify.parse("  Hello World  "); // "hello-world"

And .refine() adds a custom predicate when a built-in does not exist — cross-field rules, business logic:

TypeScript
const DateRange = z
  .object({ start: z.iso.date(), end: z.iso.date() })
  .refine((d) => d.start <= d.end, { error: "start must be on or before end" });

Keep transforms boring. A schema that quietly rewrites half its input becomes a thing nobody can reason about; validate first, transform sparingly.

Share schemas across client, server, and env

Because a Zod schema is just a value in a .ts file, you can export it from a shared package and import it in both your API and your frontend. The browser validates a form against the same CreateUser that the server enforces, and the two cannot drift apart — change the schema, both sides update. That single-source property is the strongest argument for Zod in a monorepo.

The other place it earns its keep is environment validation. A missing or malformed DATABASE_URL should crash the process at boot with a clear message, not surface as a cryptic connection error on the first request:

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

export const env = Env.parse(process.env);

Here .parse() is correct, not safeParse — if the environment is wrong you want a loud throw at startup, before any traffic arrives. Pair this with twelve-factor env config and you fail fast and obviously instead of leaking a half-configured service into production.

Where Zod is overkill

Zod is the default for request validation, but it is not free and it is not the answer everywhere.

Hot paths where validation is the bottleneck. Zod builds a rich error object and runs interpreted checks; on a route doing tens of thousands of validations a second, that overhead shows up in a flame graph. If you are on Fastify, its built-in JSON-Schema validation compiles to straight-line code via Ajv and serializes responses faster than a Zod round-trip — let the framework do it. (The Fastify + TypeScript setup wires this in by default.) Measure before you optimize, but if a profiler points at Zod on a throughput-critical endpoint, JSON Schema is the faster floor.

Tiny scripts and throwaway code. A 30-line migration script that reads one config file does not need a schema layer. if (typeof x !== "string") throw is fine. Adding Zod there is ceremony, not safety.

Data you genuinely control end to end. Output you just built in the same function, internal calls between services you own and deploy together — re-validating there is belt-and-suspenders that mostly adds latency. Validate at trust boundaries: the public edge, third-party webhooks, the database read you do not fully trust. Inside your own walls, lighten up.

Zod’s sweet spot is the untrusted boundary, where one schema replaces a pile of hand-rolled if checks and keeps your types honest at the same time. That is most API code. Just don’t cargo-cult it onto the 5% where it costs more than it returns.

FAQ

Is Zod 4 a drop-in upgrade from Zod 3?

Mostly, but not entirely. .parse(), .safeParse(), z.object(), and z.infer are unchanged. The breaking pieces are string formats moving to top-level functions (z.email() over z.string().email()), error formatting (z.treeifyError() / z.flattenError() replace the deprecated .format() / .flatten() methods), and the unified { error: "..." } parameter for custom messages. Read the official Zod 4 migration guide before bumping a large codebase.

When should I use safeParse instead of parse?

Use safeParse at any boundary where bad input is expected and normal — HTTP request bodies, query strings, webhook payloads — because it returns a result object you branch on instead of throwing. Use .parse() for data you control, like environment variables at startup or test fixtures, where invalid input means a real bug and a loud throw is what you want.

How do I get a TypeScript type from a Zod schema?

Use z.infer<typeof YourSchema>. It derives the type from the schema so you maintain one definition instead of a schema plus a parallel interface that drifts. If your schema uses .transform() or .default(), z.infer gives you the post-parse output type; reach for z.input<typeof YourSchema> when you need the raw input shape.

Why is my req.query still a string after validation?

Either you did not coerce, or you did not write the parsed value back. Use z.coerce.number() so "5" becomes 5, and make sure your middleware assigns result.data back onto the request (or onto res.locals in Express 5, where req.query is read-only). Without that write-back, your handler keeps seeing the original un-coerced object.

What is the difference between z.flattenError and z.treeifyError?

z.flattenError() returns a shallow { formErrors, fieldErrors } object, perfect for flat one-level request bodies. z.treeifyError() returns a nested tree that mirrors the schema’s structure, which you need when validating nested objects or arrays so you do not lose the path to the field that failed. Pick flatten for simple forms, treeify for nested payloads.

Does Zod slow down my API?

For typical request validation the cost is negligible against network and database latency. It becomes measurable only on high-throughput hot paths doing many thousands of validations per second, where Zod’s interpreted checks and rich error objects add up. If a profiler flags it there, Fastify’s compiled JSON-Schema validation is the faster alternative; otherwise Zod’s overhead is noise.

Can I share one Zod schema between my frontend and backend?

Yes, and it is one of the best reasons to use Zod. A schema is a plain value you can export from a shared package and import on both sides, so your client-side form validation and server-side request validation enforce identical rules and cannot drift apart. Change the schema once and both ends update together.