Building a REST API with Node.js · Lesson 11 of 30
REST API Request Validation with Zod
Every byte that reaches your API is untrusted: a buggy client, a curious user with curl, or an attacker. Validation decides what's allowed in before any business logic runs. Writing if checks by hand doesn't scale; a schema library describes the expected shape once and checks it consistently. zod is the most popular choice in the TypeScript and Node world.
In this lesson
- Schemas for request bodies
- A reusable validation middleware
- Partial schemas for PATCH and coercion for query strings
- Strict vs tolerant: unknown fields
Schemas and a validation middleware
// validate.js
import { z } from 'zod';
// Returns middleware that validates one part of the request and replaces it with the parsed value.
export const validate = (part, schema) => (req, res, next) => {
const result = schema.safeParse(req[part]);
if (!result.success) {
return res.status(422).type('application/problem+json').json({
type: 'https://api.learnfrenzy.com/problems/validation-error',
title: 'Your request has invalid fields',
status: 422,
errors: result.error.issues.map((i) => ({ field: i.path.join('.') || part, message: i.message })),
});
}
req.valid = { ...req.valid, [part]: result.data }; // validated, typed, defaults applied
next();
};
export { z };
// server.js
import express from 'express';
import { validate, z } from './validate.js';
const app = express();
app.use(express.json());
// the rules for each field – no defaults here
const EventFields = z.object({
title: z.string().trim().min(3, 'must be at least 3 characters').max(120),
date: z.iso.date(), // "YYYY-MM-DD"
seats: z.number().int().positive().max(500),
organiserEmail: z.email(),
tags: z.array(z.string()).max(5),
visibility: z.enum(['public', 'private']),
}).strict(); // reject unknown fields
// create: some fields are optional with defaults
const NewEvent = EventFields.extend({
tags: EventFields.shape.tags.default([]),
visibility: EventFields.shape.visibility.default('public'),
});
// update: every field optional, and NO defaults – missing means "don't change"
const EventUpdate = EventFields.partial().refine((v) => Object.keys(v).length > 0, 'send at least one field');
const ListQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20), // "50" (string) → 50 (number)
from: z.iso.date().optional(),
});
app.post('/api/events', validate('body', NewEvent), (req, res) => {
res.status(201).json({ id: 'evt_1', ...req.valid.body });
});
app.patch('/api/events/:id', validate('body', EventUpdate), (req, res) => {
res.json({ id: req.params.id, changed: req.valid.body });
});
app.get('/api/events', validate('query', ListQuery), (req, res) => {
res.json({ query: req.valid.query });
});
app.listen(process.env.PORT ?? 3000);
$ curl -X POST http://localhost:3000/api/events -H 'Content-Type: application/json' -d '{"title":" REST meetup ","date":"2026-12-05","seats":80,"organiserEmail":"asha@example.com"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"id": "evt_1",
"title": "REST meetup",
"date": "2026-12-05",
"seats": 80,
"organiserEmail": "asha@example.com",
"tags": [],
"visibility": "public"
}
$ curl -X POST http://localhost:3000/api/events -H 'Content-Type: application/json' -d '{"title":"Hi","date":"05/12/2026","seats":0,"organiserEmail":"asha","isAdmin":true}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://api.learnfrenzy.com/problems/validation-error",
"title": "Your request has invalid fields",
"status": 422,
"errors": [
{
"field": "title",
"message": "must be at least 3 characters"
},
{
"field": "date",
"message": "Invalid ISO date"
},
{
"field": "seats",
"message": "Too small: expected number to be >0"
},
{
"field": "organiserEmail",
"message": "Invalid email address"
},
{
"field": "body",
"message": "Unrecognized key: \"isAdmin\""
}
]
}
$ curl -X PATCH http://localhost:3000/api/events/evt_1 -H 'Content-Type: application/json' -d '{"seats":120}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"id": "evt_1",
"changed": {
"seats": 120
}
}
$ curl 'http://localhost:3000/api/events?limit=50'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"query": {
"limit": 50
}
}- A valid request comes out cleaned: the title is trimmed and defaults (
tags,visibility) are filled in. Usereq.valid, never the raw body. - An invalid request lists every problem, including the unexpected
isAdminfield rejected by.strict(). .partial()reuses the same rules for PATCH, where every field is optional – built from the field rules without defaults (see the warning below).- Query parameters are always strings;
z.coerce.number()converts and range-checks them.
Defaults and PATCH don't mix
Our first draft of this lesson built the update schema as NewEvent.partial(). Testing showed the bug: .partial() keeps .default() values, so a PATCH that only sent seats came back with tags: [] and visibility: "public" – silently resetting data the client never touched. Keep defaults on the create schema only.
Strict or tolerant?
| Option | Behaviour | Use for |
|---|---|---|
.strict() | unknown fields are an error | sensitive writes – catches typos and attempts to set fields like role |
| default (strip) | unknown fields are silently dropped | tolerant APIs where clients may send extra data |
.passthrough() | unknown fields are kept | rarely – proxies that forward data |
Validation layers
- Shape (types, required fields, formats, lengths) – the schema, at the edge.
- Business rules (enough stock, date not in the past for this plan) – the service layer, often needing the database.
- Integrity (unique email, foreign keys) – database constraints as the final safety net.
Practice
Try it yourself
Write a schema for a signup: email, password of 12+ characters, and an optional
referralCodeof exactly 8 uppercase letters or digits.Show solution
// server.js import express from 'express'; import { z } from 'zod'; const app = express(); app.use(express.json()); const Signup = z.object({ email: z.email(), password: z.string().min(12, 'use at least 12 characters'), referralCode: z.string().regex(/^[A-Z0-9]{8}$/, 'must be 8 uppercase letters or digits').optional(), }); app.post('/api/signup', (req, res) => { const r = Signup.safeParse(req.body); if (!r.success) return res.status(422).json({ errors: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) }); res.status(201).json({ email: r.data.email }); }); app.listen(process.env.PORT ?? 3000);$ curl -X POST http://localhost:3000/api/signup -H 'Content-Type: application/json' -d '{"email":"ravi@example.com","password":"short","referralCode":"ab12"}' HTTP/1.1 422 Unprocessable Entity Content-Type: application/json; charset=utf-8 { "errors": [ "password: use at least 12 characters", "referralCode: must be 8 uppercase letters or digits" ] }
Quick check
Why use the parsed value instead of req.body?
It's validated, trimmed/converted, and has defaults applied.
How do you validate a PATCH body?
Reuse the create schema with .partial().
Why coerce query parameters?
They always arrive as strings.
Summary
- Describe each request with a schema and validate it in reusable middleware.
- Return all field errors at once; use the parsed, cleaned data afterwards.
- Use .partial() for PATCH, coerce query strings, and .strict() where unknown fields are suspicious.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.