I write REST APIs for a living. Most of the bugs I have shipped were not clever concurrency problems or subtle query mistakes — they were a handler receiving something slightly different from what I assumed. A price that arrived as the string "1200". A page query param that was undefined and got silently coerced to NaN. An id that was a number in one client and a string in another.
Every one of those bugs could have been caught by a single line at the top of the handler. This post is about that line.
The bug I kept writing
Here is the shape of every route I wrote for the first year. It looks fine:
router.patch("/products/:id", async (req, res) => {
const price = Number(req.body.price);
const stock = Number(req.body.stock) || 0;
const tags = req.body.tags ? req.body.tags.split(",") : [];
const product = await Product.findByIdAndUpdate(
req.params.id,
{ price, stock, tags },
{ new: true },
);
res.json({ data: product });
});Four lines of "just get the values out", and every one of them is a place where intent and reality quietly diverge:
Number("1200abc")isNaN, and Mongoose will happily try to store it.Number(req.body.stock) || 0turns a deliberate0into0by accident, and turns"n/a"into0on purpose. The||is doing two jobs and doing neither of them correctly.tags.split(",")throws iftagsis already an array — which it will be, the moment a second client integrates.req.params.idis never checked against the 24-hex-character shape Mongo expects, so a malformed id becomes aCastErrorthrown from inside the driver.
None of this is exotic. It is the ordinary cost of treating the request object as if it were trustworthy. And the failure is always late: the error surfaces at the database layer, in a stack trace that points at Mongoose instead of at the caller who sent bad input.
Zod, briefly
Zod is a schema declaration library. You describe the shape of a value, and you get back a parser plus a TypeScript type inferred from that same declaration. One source of truth, two jobs done.
The part that matters most for this pattern is the return type of parse. Zod does not return unknown and ask you to cast it. It returns the inferred type, so the schema is the type:
import { z } from "zod";
const productBodySchema = z.object({
name: z.string().min(1).max(120).trim(),
price: z.number().nonnegative(),
stock: z.number().int().min(0).default(0),
tags: z.array(z.string().min(1)).default([]),
});
type ProductBody = z.infer<typeof productBodySchema>;
// { name: string; price: number; stock: number; tags: string[] }ProductBody is not a hand-written interface that drifts away from the schema six months later. It is derived from it. Change the schema and the type changes with it — or the compiler complains, which is the point.
A few things in that snippet are load-bearing:
.trim()onnameruns as part of the parse, so the handler never has to think about whitespace..default(0)and.default([])fire when the key is absent, not when it is present-but-empty. That distinction is exactly what the|| 0was failing to make..int()onstockrejects3.5, which is a real bug that reached production in a stock-tracking app I worked on.
Parse, don't validate
There is a difference between validating and parsing, and it is the whole reason this pattern works.
Validation asks a yes/no question and leaves the value untouched. You still hold a string afterwards, and you still have to remember, at every later use site, that it has been proven to be a number. That memory is the bug — it does not survive a refactor.
Parsing takes unknown and returns a different value whose type encodes the proof. Once you have parsed, the type system carries the guarantee for you:
const result = productBodySchema.safeParse(req.body);
// result.data is ProductBody — only reachable through the success branchThe type is the receipt. It cannot be faked, forgotten, or lost in a refactor, because you cannot call .data without having checked .success first.
The part everyone gets wrong: inputs come in four places
Most "add Zod to your API" posts show you req.body and stop. But a request has four independent sources of trusted-looking data, and they behave differently. If the pattern only covers one of them, the other three stay just as dangerous as before.
| Source | Type after Express | Behaves like | Common trap |
|---|---|---|---|
req.body | any | whatever the parser produced | Arrays and nested objects arrive as-is; JSON.parse is not your validator |
req.query | ParsedQs (always strings, may be arrays) | strings, strings, strings | ?page=1 is the string "1"; ?tag=a&tag=b is an array |
req.params | Record<string, string> | strings | Never a number, no matter how much it looks like one |
req.headers | IncomingHttpHeaders | strings or undefined or arrays | x-page-size is not a number and never will be |
The query string is where this bites hardest, because it is genuinely all strings. A schema that expects numbers will reject every well-formed request:
// Wrong — rejects ?page=1 because "1" !== 1
const listQuerySchema = z.object({
page: z.number().int().min(1).default(1),
});The fix is z.coerce, which runs the value through Number() and then applies the numeric checks. The coercion is explicit and the validation still happens after it, which is the difference between this and the Number(req.body.price) from the opening example:
const listQuerySchema = 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(["createdAt", "-createdAt", "price", "-price"]).default("-createdAt"),
search: z.string().trim().min(1).optional(),
tags: z
.union([z.string(), z.array(z.string())])
.transform((value) => (Array.isArray(value) ? value : value.split(",")))
.default([]),
});That tags field is worth staring at, because it solves the tags.split(",") crash from the start of this post properly. z.union accepts either shape, and .transform normalises both to string[]. The wildcard client that sends an array now works, and the one that sends a comma-separated string also works — and both arrive at the handler as the same type.
One small helper
Rather than repeat safeParse and the error envelope in every route, I write one factory. It takes the four schemas and returns a middleware that parses each source and replaces it with the parsed value:
import type { RequestHandler } from "express";
import { z, type ZodTypeAny } from "zod";
import { ApiError } from "../lib/api-error";
type Schemas = {
body?: ZodTypeAny;
query?: ZodTypeAny;
params?: ZodTypeAny;
};
export function validate(schemas: Schemas): RequestHandler {
return (req, _res, next) => {
for (const key of ["params", "query", "body"] as const) {
const schema = schemas[key];
if (!schema) continue;
const result = schema.safeParse(req[key]);
if (!result.success) {
return next(
new ApiError(422, "VALIDATION_ERROR", "Request failed validation", {
source: key,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
}),
);
}
// Overwrite in place so the handler sees the parsed value, not the raw one.
req[key] = result.data;
}
next();
};
}Two details carry most of the weight here.
The parsed value overwrites the raw one. This is what makes the type guarantee real at runtime rather than merely asserted. After the middleware runs, req.query genuinely is the parsed object with real numbers in it. If you instead passed the parsed value alongside the raw one, a handler could reach for the wrong one and nothing would stop it.
The error shape is structured, not a string. path and code per issue means the frontend can highlight the exact field that failed instead of showing a banner that says something went wrong. That is worth the extra ten lines.
The route, after
The same route from the top of this post, with the boundary made explicit:
router.patch(
"/products/:id",
validate({
params: z.object({ id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid id") }),
body: productBodySchema.partial(),
}),
async (req, res) => {
// req.params.id is a validated 24-char hex string.
// req.body is a Partial<ProductBody> — every field already the right type.
const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!product) {
throw new ApiError(404, "NOT_FOUND", "Product not found");
}
res.json({ data: product });
},
);Compare it to the original. There is no coercion, no || fallback doing two jobs, no .split(), no unguarded req.params.id. The handler is nine lines and every one of them is about the actual work. The parsing is not gone — it moved to the boundary, where it belongs, and it is now declarative rather than smeared across the handler body.
productBodySchema.partial() is a nice touch for PATCH specifically: it reuses the exact same field definitions and just makes every key optional. There is no second schema to keep in sync.
Types that survive the middleware
Express types req.body as any and req.query as ParsedQs, so out of the box the handler above would not know that req.body is a Partial<ProductBody>. The validation is real but the compiler cannot see it.
The clean fix is a generic ValidatedRequest type that the middleware narrows into. It is a small amount of type gymnastics for a large amount of autocomplete:
import type { Request } from "express";
import type { z, ZodTypeAny } from "zod";
type Infer<S extends ZodTypeAny | undefined> =
S extends ZodTypeAny ? z.infer<S> : unknown;
export type ValidatedRequest<S extends Schemas> = Request<
Infer<S["params"]>,
unknown,
Infer<S["body"]>,
Infer<S["query"]>
>;Then the handler is written against it:
type PatchProduct = ValidatedRequest<{
params: typeof paramsSchema;
body: typeof productBodySchema;
}>;
router.patch("/products/:id", validate({ params: paramsSchema, body: productBodySchema }), async (
req: PatchProduct,
res,
) => {
req.body.price; // number | undefined — autocompletes, and won't compile if you typo it
});Is this worth it? For a solo project with six routes, maybe not. For anything with a team on it, yes — because the alternative is that the schema and the handler are only connected by convention, and convention erodes. This makes the schema the thing the compiler reads.
What this actually buys you
After moving to this pattern across the API, four things changed that I did not fully expect going in.
Errors moved to the edge. A malformed request now fails at the boundary with a 422 and a field-level explanation, instead of travelling three layers down and surfacing as a Mongoose CastError or a ValidationError that leaks internal field names. The stack trace points at the caller's mistake rather than at my code.
The Mongoose layer got quieter. Every handler that reaches Mongoose is now guaranteed to be passing correctly-typed values, so the schema validators stopped firing. They still run — defence in depth is not redundant here — but they have gone from being the primary safety net to being the thing that catches the one time I forget a boundary.
Handlers got shorter and more honest. A handler that is nine lines of actual work, with no defensive coercion, is a handler you can read in one pass and reason about completely. That is worth more day to day than any runtime benefit.
The frontend got better errors. This one surprised me most. Because the error payload is structured, the client can map each issue to the field that caused it. Form validation stopped being a separate implementation on the frontend and became a rendering of what the server already said. One schema, one error shape, two consumers.
Takeaway
Treat the route as a trust boundary. On one side is unknown — strings from a query string, any from a JSON body, whatever a client felt like sending. On the other side is a typed value you can build on without flinching.
Zod is the wall between them. Parse once, hand the handler the parsed value, and let the type system carry the proof forward. The alternative is a Number() call at the top of every handler and the slow accumulation of bugs that only appear when a client sends something you did not imagine.
The pattern is not complicated. It is one safeParse per source and a rule about where coercion is allowed to happen. The discipline is in applying it to all four sources rather than just req.body — and in resisting the urge to write one more || fallback in a handler where the schema should have done the work.
The examples here are drawn from patterns I use in Staffic.io and my own projects. If you're working through the same problem and something here doesn't line up with your setup, get in touch — I'd like to hear where it breaks.