Node.js Backend Fundamentals with Express · Build an API with Express
Validating Request Bodies with Zod
Validate and coerce incoming JSON with Zod schemas, return useful 400 errors, and never trust req.body.
What you'll build
A POST /orders endpoint whose body is validated with a Zod schema: types, ranges and required fields are checked, unknown fields are stripped, and clients get a 400 with field-level errors.
The idea
req.bodyis untrusted input. Validate shape, types and ranges before using it.- Zod schemas describe the expected data;
schema.safeParse(value)returns either the parsed (typed, cleaned) data or detailed errors. - A small
validate(schema)middleware keeps handlers clean: it replacesreq.bodywith the parsed result or responds with 400. - Return errors as data the client can display: which field, what's wrong.
The code
import express from "express";
import { z } from "zod";
const OrderInput = z.object({
productId: z.number().int().positive(),
quantity: z.number().int().min(1).max(100),
email: z.email(),
note: z.string().max(200).optional(),
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "invalid request body",
fields: result.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
});
}
req.body = result.data; // parsed and stripped of unknown keys
next();
};
const app = express();
app.use(express.json());
app.post("/orders", validate(OrderInput), (req, res) => res.status(201).json({ received: req.body }));
const server = app.listen(0);
const post = async (body) => {
const r = await fetch(`http://127.0.0.1:${server.address().port}/orders`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
console.log(r.status, JSON.stringify(await r.json()));
};
await post({ productId: 7, quantity: 2, email: "[email protected]", isAdmin: true });
await post({ productId: "7", quantity: 0, email: "not-an-email" });
server.close();
Output
201 {"received":{"productId":7,"quantity":2,"email":"[email protected]"}}
400 {"error":"invalid request body","fields":[{"field":"productId","message":"Invalid input: expected number, received string"},{"field":"quantity","message":"Too small: expected number to be >=1"},{"field":"email","message":"Invalid email address"}]}Run this on your computer — see “Get Started” for setup.
Common mistakes
- Validating only types, not ranges (
quantity: -5or1e9). - Passing
req.bodystraight into a database query or object spread, letting clients set fields likeisAdmin. - Returning a generic "bad request" with no detail, so clients can't fix their input.
Key points
- Treat every request body, query and header as untrusted.
- Describe input with a schema; validate, coerce and strip unknown fields.
- Respond 400 with field-level errors.
Further reading: Zod documentation · OWASP Input Validation Cheat Sheet
Practice questions
Why replace req.body with the parsed result of a schema?
Answer
The parsed data has the validated types and no unknown keys, so later code can't accidentally use extra fields like isAdmin that a client added.
What should a validation error response contain?
Answer
A 400 status and a machine-readable list of field paths and messages so the client can show or fix the problems.
What is mass assignment?
Answer
Copying all client-supplied fields into a model or query, letting clients set fields they shouldn't (such as roles or prices); prevent it by whitelisting fields with a schema.
Open in the app to mark lessons complete, take notes and track your progress.