Node.js · lesson 6 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
Validating Requests with Zod
Write a Zod schema for a POST body, turn failures into 400 responses with per-field errors, and use the parsed, trimmed output instead of the raw body.
By SkillByExample · Updated
A Zod schema is both a runtime validator and, in TypeScript, a type — but even in plain JavaScript, safeParse turns an unstructured req.body into a predictable shape or a clear list of what’s wrong with it.
A Validation Middleware
// validate.js
import { z } from "zod";
export const CreateUserSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
age: z.number().int().min(0).optional(),
});
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "validation failed",
details: z.flattenError(result.error).fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}
// server.js
import express from "express";
import { CreateUserSchema, validateBody } from "./validate.js";
const app = express();
app.use(express.json());
const users = [{ id: 1, name: "Alice", email: "alice@example.com" }];
app.get("/users", (req, res) => {
res.json(users);
});
app.post("/users", validateBody(CreateUserSchema), (req, res) => {
// req.body is now CreateUserSchema's parsed output: trimmed name, a real email.
const user = { id: users.length + 1, ...req.body };
users.push(user);
res.status(201).json(user);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
node server.js & returns before Express has actually bound the port, so the loop below retries every 200 ms until a real request succeeds — and because the server’s own listening on ... line prints while that loop is still spinning, it shows up under the wait step in the output, not the line that started the process.
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":" Bob ","email":"bob@example.com"}'
{"id":2,"name":"Bob","email":"bob@example.com"}
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
{"error":"validation failed","details":{"name":["Too small: expected string to have >=2 characters"],"email":["Invalid email address"]}}
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Zoe","email":"zoe@example.com","age":-5}'
{"error":"validation failed","details":{"age":["Too small: expected number to be >=0"]}}
$ kill $!
" Bob " came back as "Bob" with no leading or trailing spaces — validateBody replaced req.body with result.data, Zod’s parsed output, not the original request body, and .trim() in the schema already ran by the time the route handler reads req.body.name. The second request fails two checks at once, and z.flattenError(...).fieldErrors groups both messages under their own field names in one response, rather than stopping at the first failure. The third shows age validated independently from name/email — it’s .optional(), so leaving it out entirely would have passed, but providing a negative number doesn’t. The final kill $! stops the server this example started — $! still refers to it because nothing else was backgrounded in between.
Practice
1. Add a role field to CreateUserSchema that must be either "admin" or "member", using z.enum.
Solution
// validate.js (added field)
import { z } from "zod";
export const CreateUserSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "member"]),
});
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "validation failed",
details: z.flattenError(result.error).fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}
validate.js changed, so this needs a fresh server process picking up the new schema — the one from the main example was already killed by its own kill $! step:
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Nora","email":"nora@example.com","role":"owner"}'
{"error":"validation failed","details":{"role":["Invalid option: expected one of \"admin\"|\"member\""]}}
$ kill $!
"owner" isn’t one of the two allowed literal strings, so z.enum rejects it with a message listing exactly what’s accepted, independent of where it fits alphabetically.
2. Change the middleware to return 422 Unprocessable Entity instead of 400 for a validation failure, and explain when that distinction actually matters to an API consumer.
Solution
// validate.js (status code changed)
import { z } from "zod";
export const CreateUserSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
age: z.number().int().min(0).optional(),
});
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(422).json({
error: "validation failed",
details: z.flattenError(result.error).fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
HTTP/1.1 422 Unprocessable Entity
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 136
ETag: W/"88-hLApVwVkHy7KchfeZN+FIfEMmBc"
Date: Thu, 29 Oct 2026 20:44:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"validation failed","details":{"name":["Too small: expected string to have >=2 characters"],"email":["Invalid email address"]}}
$ kill $!
400 means the request itself was malformed (bad JSON, missing content-type); 422 means the request was syntactically fine JSON but semantically invalid against the schema. Some API style guides pick one convention and stick to it everywhere — the distinction only matters if a client actually branches on it.
3. Write a schema for a PATCH body where every field is optional, using CreateUserSchema.partial(), and confirm an empty object {} passes.
Solution
// validate.js (added schema)
import { z } from "zod";
export const CreateUserSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
age: z.number().int().min(0).optional(),
});
export const UpdateUserSchema = CreateUserSchema.partial();
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "validation failed",
details: z.flattenError(result.error).fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}
// check-partial.js
import { UpdateUserSchema } from "./validate.js";
console.log(UpdateUserSchema.safeParse({}).success);
console.log(UpdateUserSchema.safeParse({ name: "X" }).success);
$ node check-partial.js
true
false
{} passes because every field is now optional. { name: "X" } still fails, because .partial() only makes fields optional — when name IS present, it still has to pass min(2), and "X" is one character.
4. Add a schema-level check with .refine() so CreateUserSchema rejects a name equal to "admin" (case-insensitive), with a custom error message.
Solution
// validate.js (added refine)
import { z } from "zod";
export const CreateUserSchema = z
.object({
name: z.string().trim().min(2),
email: z.email(),
age: z.number().int().min(0).optional(),
})
.refine((data) => data.name.toLowerCase() !== "admin", {
error: "name 'admin' is reserved",
path: ["name"],
});
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "validation failed",
details: z.flattenError(result.error).fieldErrors,
});
return;
}
req.body = result.data;
next();
};
}
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Admin","email":"admin@example.com"}'
{"error":"validation failed","details":{"name":["name 'admin' is reserved"]}}
$ kill $!
.refine() runs after every field already passed its own check, so it can compare across fields or apply a rule z.string() alone can’t express — path: ["name"] is what makes the custom message show up under name in fieldErrors instead of under a generic top-level key.