Request Validation
Every endpoint needs to check its params, query string and body. One reusable validate() middleware with Zod schemas makes this consistent: clean data in, clear error details out.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Write schemas for params, query and body
- Build one validate() middleware for all three
- Reject unknown fields with strict objects
- Return error details clients can map to fields
Explanation#
Group the schemas per endpoint: { params, query, body }. validate(schemas) parses each part and collects every problem, so the client sees all errors at once.
Params and query values arrive as strings — use z.coerce.number() and .default(). Bodies use exact types: z.number() rejects "499".
z.strictObject (or .strict()) rejects unknown keys, which blocks mass-assignment attempts such as { "role": "admin" }. Plain z.object silently strips them — also safe, but less visible.
Transformations clean data on the way in: .trim(), .toLowerCase(), defaults. Handlers read req.valid.body, never the raw req.body.
Error details: an array of { in, field, message } lets front ends show messages under the right inputs.
Syntax#
router.post('/', validate({ body: CreateCourse }), controller.create);
router.get('/:courseId/lessons', validate({ params: CourseParams, query: Paging }), controller.lessons);
Code example: One validate() middleware for params, query and body#
import express from 'express';
import { z } from 'zod';
const CreateCourse = z.strictObject({
title: z.string().trim().min(3).max(120),
level: z.enum(['beginner', 'intermediate', 'advanced']),
price: z.number().int().min(0).max(100_000),
tags: z.array(z.string().trim().toLowerCase()).max(5).default([]),
});
const CourseParams = z.object({ courseId: z.coerce.number().int().positive() });
const Paging = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20) });
function validate(parts) {
return (req, res, next) => {
const details = [];
req.valid = {};
for (const [where, schema] of Object.entries(parts)) {
const result = schema.safeParse(req[where] ?? {});
if (result.success) req.valid[where] = result.data;
else for (const issue of result.error.issues) {
details.push({ in: where, field: issue.path.join('.') || null, message: issue.message });
}
}
if (details.length) return res.status(400).json({ error: 'Validation failed', details });
next();
};
}
const app = express();
app.use(express.json());
app.post('/api/courses', validate({ body: CreateCourse }), (req, res) => res.status(201).json(req.valid.body));
app.get('/api/courses/:courseId/lessons', validate({ params: CourseParams, query: Paging }),
(req, res) => res.json({ courseId: req.valid.params.courseId, limit: req.valid.query.limit }));
const server = app.listen(3105, async () => {
const base = 'http://localhost:3105/api/courses';
const show = async (label, res) => {
const body = await res.json();
console.log(body.details ? `${label} ${res.status}` : `${label} ${res.status} ${JSON.stringify(body)}`);
for (const d of body.details ?? []) console.log(` ${d.in}.${d.field ?? '(object)'}: ${d.message}`);
};
const post = body => fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
await show('valid ', await post({ title: ' Node.js REST APIs ', level: 'beginner', price: 499, tags: ['Node', ' API '] }));
await show('invalid', await post({ title: 'JS', level: 'expert', price: 9.5, role: 'admin' }));
await show('params ', await fetch(base + '/abc/lessons?limit=500'));
await show('default', await fetch(base + '/7/lessons'));
server.close();
});
valid 201 {"title":"Node.js REST APIs","level":"beginner","price":499,"tags":["node","api"]}
invalid 400
body.title: String must contain at least 3 character(s)
body.level: Invalid enum value. Expected 'beginner' | 'intermediate' | 'advanced', received 'expert'
body.price: Expected integer, received float
body.(object): Unrecognized key(s) in object: 'role'
params 400
params.courseId: Expected number, received nan
query.limit: Number must be less than or equal to 50
default 200 {"courseId":7,"limit":20}This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- CreateCourse trims the title, lower-cases tags and fills tags with [] when missing.
- strictObject turns the extra role field into an error instead of silently accepting it.
- validate() checks each part and collects all issues before answering 400.
- Params and query are coerced from strings; "abc" fails and 500 exceeds the maximum.
- Valid requests reach the handler with typed, cleaned data in req.valid.
Real-world use case#
LearnHub’s React course editor shows each message from details under the matching input. The same Zod schemas are imported by the front end, so both sides validate with identical rules.
Common mistakes#
Unvalidated ids and query values cause 500s (NaN in SQL) or huge queries (limit=1000000).
Users fix one error, submit, and get the next. Report all issues at once.
The raw body is untrimmed and may contain extra fields. Use the parsed result.
Best practices#
- Schemas for params, query and body
- strictObject for create/update bodies
- Coerce and cap query values
- Return all issues with field paths
Remember this#
validate({ params, query, body }) → req.valid.
Strings in params/query → z.coerce.
Unknown fields → strictObject rejects them.
Practice exercise#
Your task: Write an UpdateCourse schema for PATCH: every field optional, but at least one must be sent.
Need a hint?
Show solution
import { z } from 'zod';
const CreateCourse = z.strictObject({
title: z.string().trim().min(3).max(120),
level: z.enum(['beginner', 'intermediate', 'advanced']),
price: z.number().int().min(0),
});
const UpdateCourse = CreateCourse.partial().refine(obj => Object.keys(obj).length > 0, { message: 'Send at least one field to update' });
console.log(UpdateCourse.safeParse({ price: 399 }).success); // true
console.log(UpdateCourse.safeParse({}).error.issues[0].message); // Send at least one field to update
Mini quiz#
What does z.strictObject do with { title, role: "admin" } when role is not in the schema?
Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions
My notes#
Saved in this browser.