Zod with Express: Stop Writing your Types Twice
I had two files open side by side. The one on the left declared a CreatePersonInput interface. Name was required, birth year was optional. The one on the right checked incoming request bodies by hand. They had already drifted. The interface said birth year was optional, but the checks still rejected any request without it. That is the quiet failure mode of a typed Express API, and it is what Zod with Express fixes. TypeScript describes the data you hope arrives. It does nothing when the request actually lands.
🚀 Complete JavaScript Guide (Beginner + Advanced)
🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)
TypeScript is already gone when the request lands
Express types req.body as any. You can misspell birthYear and it compiles. The body can arrive completely empty and it compiles. So people write the checks by hand. For one field that is fine. This endpoint has four.
Now imagine the app has 30 endpoints, and every one of them carries its own little pile of if statements that has to stay in sync with a type definition sitting in another file. Nobody keeps 30 of those in sync. More checks will not save you. Collapsing the checks and the type into one object will.
// the shape, written once...
export interface CreatePersonInput {
name: string;
birthYear?: number;
deathYear?: number;
note?: string;
}
// ...and written again, by hand, in the route
if (!req.body.name) {
return res.status(400).json({ error: "name is required" });
}
if (!req.body.birthYear) {
// already out of sync with the interface above
return res.status(400).json({ error: "birthYear is required" });
}
Zod with Express gives you one source of truth
Zod is a schema library. You describe the data once and get two things back. First, a runtime validator. Hand it an unknown blob and it returns clean typed data, or it tells you exactly what was wrong. Second, the TypeScript type, inferred from that same schema. You never write the type separately, so the type and the validator cannot drift apart.
// src/schemas/person.ts
import { z } from "zod";
export const createPersonSchema = z.object({
name: z.string(),
birthYear: z.number().optional(),
deathYear: z.number().optional(),
note: z.string().optional(),
});
export type CreatePerson = z.infer<typeof createPersonSchema>;
Make birthYear required in the schema and CreatePerson changes with it. The drift I opened with is now structurally impossible, because there is no second file to fall out of sync.
Wiring it into the handler replaces every hand-written check:
const result = createPersonSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(z.flattenError(result.error));
}
const person = addPerson(result.data); // result.data is fully typed
safeParse does not throw. It returns an object with success: false on failure, so you check it and respond. z.flattenError hands you formErrors and fieldErrors, keyed by field name with a message each. That is a response the front end can render next to the right input, and I wrote zero lines for it. One version note: in Zod 3 you called result.error.flatten(), and Zod 4 moved it to a top-level function.
The middleware works, then the type disappears
Repeating safeParse, the success check, and the error response in every handler gets old fast. A middleware factory fixes that. You hand it a schema, it validates req.body, and it either rejects the request with a 400 or assigns the parsed data back and calls next(). I handed that one to Claude Code, because I had already worked out the mechanism and only needed it typed up. One job, body only, no auth and no logging.
Then you hover over req.body in the route and it is any again. At runtime the body is clean. TypeScript still types it as whatever Express says, which is basically nothing. Zod with Express kills the drift, but it does not automatically carry the type across the middleware boundary. There are two ways out.
Cast when the middleware is the only door
const body = req.body as CreatePerson;
I normally dislike casts, however this one is different. The middleware is the only door into the handler, and it opens only for validated data. So as CreatePerson asserts exactly what the middleware already enforces at runtime. The cast and the check agree because they came from the same schema. That is different from casting to silence a compiler error.
Generics when you have thirty routes
Make the middleware generic over the schema and the validated type flows into the handler on its own. No cast to remember. Then look at the middleware signature. For three routes you maintain generic machinery to save three casts you can read at a glance. For 30 routes the type flows everywhere and nobody forgets, so the trade pays off. Start with the cast and move to generics when remembering it becomes the bigger risk.
safeParse at the edge, parse at internal boundaries
The middleware guards the network edge, where the data is untrusted. My service layer is a different situation. By the time a person reaches the function that stores it, the data should already be correct. If it is wrong there, that is a bug in my own code, not user error.
// src/services/person.ts
import { createPersonSchema } from "../schemas/person.js";
export function addPerson(input: unknown) {
const person = createPersonSchema.parse(input); // throws on bad data
people.push(person);
return person;
}
parse throws, and that is the point. Catch it in the service, in the route handler, or in a global error handler, whichever fits your app. What you do not want is data degrading quietly. So the rule is short. Use safeParse at the untrusted edge. Use parse at internal boundaries where bad data means a bug.
Key takeaways
- TypeScript interfaces describe the body you hope to receive, and they enforce nothing at the moment a request actually arrives.
- A Zod schema produces both the runtime validator and the TypeScript type, so the two cannot fall out of sync.
- A validation middleware removes the repetition but strips the type, and you get it back with either a cast or a generic middleware signature.
- The cast is honest here because the middleware is the only path into the handler and it only admits validated data.
- Use
safeParsewhere the data is untrusted andparsedeeper in the app, where bad data should fail loudly.
Conclusion
Pick the endpoint in your own API where a bad body would do the most damage. Write the schema, derive the type from it, and delete the interface and the hand-written checks that used to guard it. That is the whole point of Zod with Express. You stop keeping the validator and the type in sync, because there is nothing left to keep in sync.