Node.js REST API · Robustness

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.

Intermediate 6 min Lesson 7 of 25
0/25 in Node.js REST API
On this page · 6 min · Intermediate

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#

JavaScript Illustrative
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#

JavaScriptvalidate.js Illustrative
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();
});
Expected output
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#

  1. CreateCourse trims the title, lower-cases tags and fills tags with [] when missing.
  2. strictObject turns the extra role field into an error instead of silently accepting it.
  3. validate() checks each part and collects all issues before answering 400.
  4. Params and query are coerced from strings; "abc" fails and 500 exceeds the maximum.
  5. 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#

Validating only the body

Unvalidated ids and query values cause 500s (NaN in SQL) or huge queries (limit=1000000).

Stopping at the first error

Users fix one error, submit, and get the next. Report all issues at once.

Using req.body after validating

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?
CreateCourse.partial() makes fields optional; .refine(obj => Object.keys(obj).length > 0, …) requires one.
Show solution
JavaScript Illustrative
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#

Request Validation quizMultiple choice Question 1 of 3

What does z.strictObject do with { title, role: "admin" } when role is not in the schema?

Score so far: 0/0

Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.