The most expensive bug I keep seeing in Node APIs is also the most boring one. A request comes in, the handler does const { id, amount } = req.body, and three hops later the database gets amount: "100" as a string and silently inserts garbage. No 500. No log. Just a row that should not exist.

TypeScript can't catch this. Compile-time types are gone by the time the V8 process is running, and as User is a promise to the compiler, not a check on the data. The fix is the same fix Java, Go, Python and Rust services have always used at the edge: a schema that runs.

Node's ecosystem has two things going for it here. The libraries are mature, and you only need to add validation in one place — the request boundary — to fix most of the surface.

Pick One Library And Use It Everywhere

The current dominant choice is Zod. It runs everywhere, infers TypeScript types from schemas, and the API stays out of your way. Two real alternatives:

  • Valibot — newer, focused on tree-shaking and bundle size. Compelling when you ship the same schema to a browser or edge function.
  • Yup — older, fine for forms, less idiomatic on the backend in 2024.

Joi is still around in legacy codebases. It works, but it predates the "infer the type from the schema" pattern that makes Zod and Valibot pleasant.

The point is consistency. One library across the codebase means your error shape is consistent, your .errors property is in the same place, and reviewers don't have to relearn the syntax in every PR.

Validate Body, Query, Params, And Headers — Not Just Body

Most tutorials show you req.body. Production traffic has four untrusted inputs per request:

TypeScript src/routes/orders.ts
import { z } from 'zod';
import type { Request, Response } from 'express';

const Body = z.object({
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
  notes: z.string().max(500).optional(),
});

const Query = z.object({
  source: z.enum(['web', 'mobile', 'pos']).default('web'),
});

const Params = z.object({
  customerId: z.uuid(),                              // z.string().uuid() in Zod 3
});

const Headers = z.object({
  'idempotency-key': z.string().min(8),
});

export async function createOrder(req: Request, res: Response) {
  const body = Body.parse(req.body);
  const query = Query.parse(req.query);
  const params = Params.parse(req.params);
  const headers = Headers.parse(req.headers);
  // ...
}

The Headers schema is the one most teams skip. An idempotency key, a tenant ID, an API version — these all arrive in headers and end up shaping behavior. If they're optional in the type system but required by the logic, you have a runtime bug waiting.

Coerce Strings That Are Really Numbers Or Dates

Query strings and headers are always strings. Path params too. So ?page=2 arrives as "2", and a naïve z.number() will reject it. Zod ships z.coerce for exactly this:

TypeScript src/routes/list.ts
const ListQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  perPage: z.coerce.number().int().min(1).max(100).default(20),
  since: z.coerce.date().optional(),
});

z.coerce.number() converts "2" into 2, then validates. z.coerce.date() accepts ISO strings or epoch milliseconds and returns a real Date. The endpoint contract stays clean — the consumer sends strings, the handler receives typed values.

Don't reach for coerce inside the body schema unless the client genuinely sends strings. JSON bodies should send numbers as numbers; if you're coercing, you're papering over a frontend bug.

parse Vs safeParse — Choose Based On The Caller

Both methods exist for a reason.

  • parse throws a ZodError on failure. Use it when an error handler upstream knows how to turn that into a 400.
  • safeParse returns { success, data } or { success, error }. Use it when the handler itself decides what to do — log, fall back, return a partial result.

The cleanest pattern I've landed on is parse inside route handlers paired with one Express error middleware that knows about ZodError:

TypeScript src/middleware/error.ts
import { ZodError } from 'zod';
import type { ErrorRequestHandler } from 'express';

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof ZodError) {
    // Zod 4: use z.flattenError() / z.treeifyError(); the chained .flatten() is the Zod 3 form
    return res.status(400).json({ error: 'invalid_input', issues: z.flattenError(err) });
  }
  return res.status(500).json({ error: 'internal' });
};

Now every handler stays a one-liner: const body = Schema.parse(req.body). The error path is centralized, the response shape is consistent, and safeParse is reserved for the cases where you genuinely need to branch on the result.

Inline diagram showing four input streams — body, query, params, headers — each entering a validation gate before flowing into the typed handler core, with rejected requests fanning back out as 400 responses.
Validate every untrusted input. The handler should never see raw req properties.

One Schema Per Endpoint, Co-Located With The Route

Avoid the temptation to build a giant schemas/ folder of reusable shapes early. The schema is part of the endpoint contract; if you change the endpoint, you change the schema. Co-locate them:

Text
src/routes/
  orders/
    create.ts        // CreateOrderBody + handler
    list.ts          // ListOrdersQuery + handler
    get.ts           // GetOrderParams + handler

When a shape genuinely repeats — Pagination, Money, Address — extract it then. Premature centralization is how you end up with BaseSchema and BaseSchemaV2 six months later.

Share Schemas Between Client And Server When You Can

If your frontend lives in the same repo, a shared package of Zod schemas removes a whole category of bugs. The form validates with the same rules the API enforces, the success type is identical on both sides, and a breaking change in the contract becomes a TypeScript error in the frontend instead of a 400 in production.

TypeScript packages/contracts/src/orders.ts
import { z } from 'zod';

export const CreateOrderBody = z.object({
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
});

export type CreateOrderBody = z.infer<typeof CreateOrderBody>;

Both the React form and the Express handler import CreateOrderBody. The frontend uses it with react-hook-form or a custom resolver. The backend uses it with parse. Same source of truth, two consumers.

If your client is in a different repo, publish the schemas as a small package or generate them from an OpenAPI doc. The shared-types pitch sounds heavy until you've shipped it once — then you stop accepting "the contract is in the docs" as an answer.

A One-Sentence Mental Model

Treat req.body, req.query, req.params, and req.headers as unknown, run them through a schema, and let the typed result be the only thing the rest of your code touches. Do that consistently and you'll spend a lot less time staring at silent inserts and a lot more time shipping features 👊