The API from the previous lesson has a hole the size of a lorry: if somebody sends {"name": "", "priceEuros": "very expensive", "stock": -5, "colour": "red"}, the coffee is created. eurosToCents("very expensive") produces NaN, the stock ends up negative, the colour field is stored without anyone having planned for it, and from then on GET /v1/coffees returns "priceEuros": null forever. The hand-written checks we left scattered along the way are incomplete, duplicated and each one invents its own message. Today we replace them with declarative schemas using Zod and a single validate(schema, source) middleware that rejects strictly, coerces the types of the query parameters and returns all the failures at once in details, in the exact format we settled on in 02-04. This is the lesson that turns the API into something you can expose to a consumer other than yourself.
Contents
- Why input is never trusted
- What gets validated: body, path, query and headers
- Manual validation versus schema validation
- First steps with Zod:
parseandsafeParse - The types Aroma Store needs
z.coerceand the query-parameter problem- Strict input with
.strict() - Composite rules with
refineandsuperRefine - Normalisation with
transform - One schema per operation:
partial,extendandmerge src/schemas/common.jssrc/schemas/coffees.jssrc/schemas/orders.js- The
validate(schema, source)middleware - From
ZodErrorto the contract's error format - The routes, with validation
- Invalid requests and their exact responses
- Business validation: why it lives in the service
- Sanitisation, normalisation and the size limit
- Alternatives to Zod and the link with OpenAPI
- Why input is never trusted
The rule is as old as web development and admits no nuance: everything that arrives over the network is hostile until proven otherwise. Not because every consumer is malicious —Aroma Store's SPA is not— but because:
| Reason | Example in Aroma Store |
|---|---|
| Honest mistakes | Aroma Mobile sends priceEuros as a string because of a bug in its form |
| Out-of-date clients | An old version of the app sends roast: "roasted", which no longer exists |
| Third-party integrations | Our partner SwiftShip sends dates in day-first format (14/03/2026) |
| Attacks | Someone tries stock: -999999 or a 500 MB body just to see what happens |
| The client is untrustworthy by definition | Even if the SPA validates, anyone can call the API with curl |
That last point is the decisive one. Client-side validation is usability; server-side validation is correctness. The former exists so the user does not have to wait for the server to say the field is empty; the latter is the only thing that genuinely protects the data, because the API is public and nobody is obliged to go through your SPA to call it.
The consequences of not validating fall into two families:
- Integrity. Corrupt data that propagates: a
NaNstored today breaks an invoice three months from now, and by then the origin of the problem is impossible to trace. It also contaminates aggregate calculations: one null price makes the sales report lie. - Security. SQL injection, NoSQL injection, prototype pollution, denial of service with gigantic bodies or pathological regular expressions. Here we will only see how validation shuts the front door; the complete catalogue of threats and their defences is lesson 04-02.
One principle organises everything else: validate at the edge, trust inside. Once the request has passed the validation middleware, the rest of the code —controller, service, repository— can take for granted that data.priceEuros is a positive number with two decimals. If every layer checks again, the code fills up with useless defensiveness and nobody knows who is responsible for what.
- What gets validated: body, path, query and headers
A frequent mistake is validating only the body. There are four inputs and all four are equally manipulable:
| Source | What it contains | Example attack or mistake |
|---|---|---|
Body (req.body) |
The resource's data | priceEuros: -10, unknown field isAdmin: true |
Path (req.params) |
Identifiers | /v1/coffees/../../etc/passwd, /v1/coffees/'; DROP TABLE |
Query (req.query) |
Filters, sorting, pagination | limit=999999, sort=(select…) |
Headers (req.headers) |
Protocol metadata | Unexpected Content-Type, missing Idempotency-Key |
In Aroma Store we will validate the first three with schemas. Headers are checked on a case-by-case basis, because their semantics belong to the protocol rather than the domain: the Content-Type of PATCH is already checked in the controller (03-03) and the mandatory Idempotency-Key on POST /v1/orders will be checked in a middleware of its own.
- Manual validation versus schema validation
This is how the manual check for POST /v1/coffees ended up in 03-03, and it only covered the required fields:
const requiredFields = ['name', 'origin', 'roast', 'priceEuros', 'stock'];
const missing = requiredFields.filter((field) => data?.[field] === undefined);
if (missing.length > 0) { /* ... 400 ... */ }To cover the full contract we would have to add: that name is a non-empty string of reasonable length, that roast is one of three values, that priceEuros is a positive number with two decimals, that stock is a non-negative integer, that tastingNotes is an array of strings, that there are no unknown fields… and then repeat it for PUT, and for PATCH with everything optional, and again for orders. That would be two hundred lines of if statements to keep in sync with the documentation.
| Criterion | Manual (if) |
Declarative schema |
|---|---|---|
| Readability | Lost among conditionals | The schema is the specification |
| All errors at once | You accumulate them by hand | Out of the box |
| Type conversion | Manual and error-prone | Built in |
| Reuse across POST/PUT/PATCH | Copy and paste | extend, merge, partial |
| Documentation | Drifts out of sync | Generates JSON Schema and OpenAPI |
| Cost of a new rule | An if in every place |
One line in one place |
A declarative schema states what must hold, not how to check it. And because it is an object, it can be transformed: from one Zod schema you get a validator, TypeScript types and a JSON Schema for OpenAPI. From an if you get nothing.
- First steps with Zod:
parse and safeParse
parse and safeParseZod has been installed since 03-01. The examples use the stable API, common to versions 3 and 4.
import { z } from 'zod';
// A schema is an object describing the shape of a piece of data.
const nameSchema = z.string().min(3).max(120);
// parse() returns the validated value or THROWS a ZodError.
nameSchema.parse('Ethiopia Yirgacheffe'); // → 'Ethiopia Yirgacheffe'
nameSchema.parse('ab'); // → throws ZodError
// safeParse() never throws: it returns a discriminated result.
const result = nameSchema.safeParse('ab');
console.log(result.success); // false
console.log(result.error.issues);
// [{ code: 'too_small', minimum: 3, path: [], message: 'String must contain at least 3 character(s)' }]| Method | Returns | When to use it |
|---|---|---|
parse(v) |
The validated value, or throws | Inside a try, or when a failure is a bug |
safeParse(v) |
{ success: true, data } or { success: false, error } |
In the middleware: failure is expected |
In Aroma Store we will use safeParse, because an invalid body is not an exception: it is the normal case of a client making a mistake, and its response is a well-formed 400, not a program error.
The essential part of the error object: the issues property is an array with all the problems found, not just the first one. Each issue has:
| Property | What it is | Example |
|---|---|---|
path |
Path to the field, as an array | ['items', 0, 'quantity'] |
code |
Kind of problem | invalid_type, too_small, unrecognized_keys |
message |
Human-readable message | 'Expected number, received string' |
That complete issues array is exactly what the contract needs: validation returns every failure at once (02-04), so the consumer fixes their request in one go rather than in five attempts.
- The types Aroma Store needs
A tour of the constructors we will use, each with its real case:
import { z } from 'zod';
// --- Strings ---
z.string(); // must be a string
z.string().min(1, 'It cannot be empty');
z.string().max(120);
z.string().trim(); // trims whitespace BEFORE validating
z.string().email(); // the customer's email address
z.string().regex(/^cof_\d{3}$/, 'Invalid coffee id format');
// --- Numbers ---
z.number(); // must be a number (not a string)
z.number().int('It must be an integer');
z.number().positive(); // > 0
z.number().nonnegative(); // >= 0, the right rule for 'stock'
z.number().max(10000);
// --- Booleans and enums ---
z.boolean();
z.enum(['light', 'medium', 'dark']); // roast
z.enum(['pending_payment', 'paid', 'shipped']); // order status
// --- ISO-8601 dates in UTC, as settled in 02-05 ---
z.string().datetime({ message: 'It must be an ISO-8601 date in UTC with Z' });
// --- Arrays ---
z.array(z.string()).max(10); // tastingNotes: at most 10 notes
z.array(orderItemSchema).min(1, 'The order must have at least one item');
// --- Objects ---
z.object({ name: z.string(), stock: z.number() });
// --- Modifiers ---
z.string().optional(); // may be absent (undefined)
z.string().nullable(); // may be null
z.string().default(''); // filled in when absentThe case of priceEuros is worth stopping at. The contract of 02-05 says "euros with two decimals on the outside, cents on the inside". Zod has no "decimal with two places" type, so it is composed:
const priceEuros = z
.number()
.positive('The price must be greater than zero')
.max(1000, 'The price cannot exceed €1000')
.refine((value) => Number.isInteger(Math.round(value * 100)) && (value * 100) % 1 < 1e-9, {
message: 'The price accepts two decimals at most',
});A more readable and more robust way of expressing the same thing, avoiding floating-point noise, is to check the textual representation:
const priceEuros = z
.number()
.positive('The price must be greater than zero')
.max(1000, 'The price cannot exceed €1000')
.refine((value) => /^\d+(\.\d{1,2})?$/.test(String(value)), {
message: 'The price accepts two decimals at most',
});This way 14.5 and 14.50 both pass (they are the same number), and 14.567 is rejected with a 400. Accepting three decimals would mean accepting fractions of a cent that would be lost in the conversion, and with them the accounting balance.
z.coerce and the query-parameter problem
z.coerce and the query-parameter problemAs we saw in 03-03, everything arriving in the URL is text. ?limit=20&available=true produces { limit: '20', available: 'true' }. A z.number() applied to '20' fails, and rightly so.
z.coerce converts before validating:
z.coerce.number().int().min(1).max(100).parse('20'); // → 20 (a number)
z.coerce.number().parse('abc'); // → fails: NaN is not a number
z.coerce.boolean().parse('true'); // → trueCareful with z.coerce.boolean(): it applies JavaScript's conversion, where any non-empty string is truthy. So ?available=false would become true, which is exactly the bug we fixed in exercise 1 of 03-03. For booleans in the URL you have to be explicit:
// Correct: only 'true' and 'false', anything else is a 400.
const queryBoolean = z
.enum(['true', 'false'], { message: "Only 'true' or 'false' is accepted" })
.transform((v) => v === 'true');This is the kind of detail that separates an API that seems to work from one that works.
- Strict input with
.strict()
.strict()By default, z.object() silently discards keys that are not in the schema. The contract of 02-05 decided the opposite: strict input, unknown field → 400.
const lax = z.object({ name: z.string() });
lax.parse({ name: 'Kenya', colour: 'red' }); // → { name: 'Kenya' }, the colour is lost
const strict = z.object({ name: z.string() }).strict();
strict.parse({ name: 'Kenya', colour: 'red' }); // → fails: unrecognized_keysThe three reasons behind the decision, worth having to hand because the debate always resurfaces:
- It catches client typos. Someone sending
priceEuro(without thes) gets a cheerful201and a coffee with a default price under the lax version. Under the strict one, they get a400telling them exactly which field does not exist. - It prevents mass assignment. If tomorrow the internal model had a
featuredorcustomerRolefield, a body including it could end up being stored if the code does{...data}. Strict input shuts that door at the edge. - It makes evolution explicit. Adding a field to the API becomes a conscious decision reflected both in the schema and in
openapi.yaml.
The honest trade-off: strict input reduces tolerance. A client that echoes back a representation we returned —_links and createdAt included— will get a 400. It is a real and frequent case with PUT. It is solved by documenting it clearly and, if necessary, explicitly ignoring the read-only fields in the schema instead of rejecting them.
- Composite rules with
refine and superRefine
refine and superRefineSome rules belong not to one field but to the relationship between several. refine adds an arbitrary check:
// A coherent price range in the filters
const priceRangeSchema = z
.object({
priceMin: z.coerce.number().nonnegative().optional(),
priceMax: z.coerce.number().nonnegative().optional(),
})
.refine(
(data) =>
data.priceMin === undefined ||
data.priceMax === undefined ||
data.priceMin <= data.priceMax,
{ message: "'priceMin' cannot be greater than 'priceMax'", path: ['priceMin'] }
);The path matters: without it, the failure is not associated with any field and the error's details ends up with no field.
superRefine lets you emit several problems and choose their code:
const dateRangeSchema = z
.object({
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
})
.superRefine((data, ctx) => {
if (data.dateFrom && data.dateTo && data.dateFrom > data.dateTo) {
ctx.addIssue({
code: 'custom',
path: ['dateFrom'],
message: "'dateFrom' must be earlier than 'dateTo'",
});
}
});Where the boundary lies. The schema holds the rules that can be checked by looking only at the request: formats, ranges, coherence between fields. Anything that needs to consult the state of the system —does that coffee exist? is there stock? has this order already been paid?— does not belong here. We will come back to this in section 18, because it is the most common confusion of this lesson.
- Normalisation with
transform
transformtransform changes the value after validating it. It serves us in two ways:
// 1. Cleaning input: trimming whitespace, normalising capitalisation
const name = z.string().trim().min(1).max(120);
const origin = z
.string()
.trim()
.min(1)
.transform((v) => v.charAt(0).toUpperCase() + v.slice(1).toLowerCase()); // 'COLOMBIA' → 'Colombia'
// 2. Converting to the internal unit: euros → cents
const price = priceEuros.transform((euros) => Math.round(euros * 100));The second temptation is strong and must be resisted in part. If the schema returned priceCents, the controller would receive an object that no longer looks like the public contract, and the unit translation would be split between the schema and the mapper. In Aroma Store we keep the conversion in the service (eurosToCents, 03-03) and use transform only for normalising: trimming whitespace, unifying the capitalisation of the origin, removing duplicate tasting notes. A useful rule: transform cleans up what the client typed; it does not translate between the public world and the internal one.
- One schema per operation:
partial, extend and merge
partial, extend and mergePOST, PUT and PATCH do not ask for the same thing, so they do not share a schema; they share pieces.
| Operation | Schema | Rule |
|---|---|---|
POST /v1/coffees |
createCoffeeSchema |
Every field required except the contract's optional ones |
PUT /v1/coffees/:id |
replaceCoffeeSchema |
The same as creating: PUT replaces the whole resource |
PATCH /v1/coffees/:id |
modifyCoffeeSchema |
Everything optional, but at least one field |
And the composition tools:
const base = z.object({ name: z.string(), origin: z.string() });
base.partial(); // every field optional
base.extend({ stock: z.number() }); // adds fields
base.merge(otherSchema); // merges two objects
base.pick({ name: true }); // only some of them
base.omit({ origin: true }); // all but someOne ordering detail that costs an afternoon if you find it out the hard way: .strict() is applied last. base.strict().partial() works, but if you chain extend after strict, it is worth closing it again. That is why in our files .strict() is always the final call.
src/schemas/common.js
src/schemas/common.jsWe start with the shared pieces, so as not to repeat them in every resource:
// src/schemas/common.js
import { z } from 'zod';
/** Prefixed identifiers, exactly as settled in 02-02. */
export const coffeeIdSchema = z.string().regex(/^cof_\d{3}$/, "The id must look like 'cof_001'");
export const customerIdSchema = z.string().regex(/^cus_\d+$/, "The id must look like 'cus_842'");
export const orderIdSchema = z.string().regex(/^ord_\d+$/, "The id must look like 'ord_5001'");
/** Query-string boolean: only 'true' or 'false'. */
export const queryBoolean = z
.enum(['true', 'false'], { message: "Only 'true' or 'false' is accepted" })
.transform((v) => v === 'true');
/** An amount in euros with two decimals at most (02-05). */
export const priceEuros = z
.number()
.positive('The price must be greater than zero')
.max(1000, 'The price cannot exceed €1000')
.refine((v) => /^\d+(\.\d{1,2})?$/.test(String(v)), {
message: 'The price accepts two decimals at most',
});
/** ISO-8601 date in UTC with a trailing Z. */
export const isoDate = z.string().datetime({ message: 'It must be ISO-8601 in UTC, ending in Z' });
/**
* Offset pagination, with the values from the contract of 02-06:
* limit defaults to 20 with a maximum of 100; offset maximum 10,000.
* Nothing is clamped in silence: out of range means 400.
*/
export const pagination = {
limit: z.coerce
.number()
.int('The limit must be an integer')
.min(1, 'The minimum limit is 1')
.max(100, 'The maximum limit is 100')
.default(20),
offset: z.coerce
.number()
.int('The offset must be an integer')
.min(0)
.max(10000, 'The maximum offset is 10,000; use narrower filters')
.default(0),
};
/** A comma-separated list of fields: 'id,name,priceEuros'. */
export const fieldList = z
.string()
.regex(/^[a-zA-Z]+(,[a-zA-Z]+)*$/, 'It must be a comma-separated list of fields');The default() calls matter: thanks to them the controller always receives limit and offset as numbers, and the req.query.limit === undefined ? 20 : ... lines from 03-03 disappear.
src/schemas/coffees.js
src/schemas/coffees.js// src/schemas/coffees.js
import { z } from 'zod';
import {
coffeeIdSchema,
priceEuros,
pagination,
queryBoolean,
fieldList,
} from './common.js';
/** Fields of the coffee resource that the client may write. */
const coffeeFields = {
name: z.string().trim().min(3, 'The name needs at least 3 characters').max(120),
origin: z.string().trim().min(2).max(80),
roast: z.enum(['light', 'medium', 'dark'], {
message: "The roast must be 'light', 'medium' or 'dark'",
}),
priceEuros,
stock: z.number().int('The stock must be an integer').nonnegative('The stock cannot be negative'),
tastingNotes: z
.array(z.string().trim().min(1).max(40))
.max(10, 'At most 10 tasting notes')
.default([]),
description: z.string().trim().max(2000).nullable().default(null),
};
/** POST /v1/coffees — every field except those that have a default. */
export const createCoffeeSchema = z.object(coffeeFields).strict();
/** PUT /v1/coffees/:id — full replacement: the same demands as creating. */
export const replaceCoffeeSchema = createCoffeeSchema;
/** PATCH /v1/coffees/:id — everything optional, but at least one field. */
export const modifyCoffeeSchema = z
.object(coffeeFields)
.partial()
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: 'The body of a PATCH cannot be empty',
});
/** Path parameters of /v1/coffees/:id */
export const coffeeIdParamsSchema = z.object({ id: coffeeIdSchema }).strict();
/** Query parameters of GET /v1/coffees, per the contract of 02-06. */
export const coffeeQuerySchema = z
.object({
origin: z.string().trim().min(1).optional(),
roast: z.enum(['light', 'medium', 'dark']).optional(),
priceMin: z.coerce.number().nonnegative().optional(),
priceMax: z.coerce.number().nonnegative().optional(),
available: queryBoolean.optional(),
q: z.string().trim().min(2, 'The search needs at least 2 characters').max(80).optional(),
sort: z.string().optional(),
fields: fieldList.optional(),
limit: pagination.limit,
offset: pagination.offset,
})
.strict()
.refine(
(d) => d.priceMin === undefined || d.priceMax === undefined || d.priceMin <= d.priceMax,
{ message: "'priceMin' cannot be greater than 'priceMax'", path: ['priceMin'] }
);The .strict() on the query schema is what fulfils the promise of 02-06 that an unknown parameter produces a 400. ?pageSize=20 (the most frequent slip, because half the APIs out there call it that) stops being a silently ignored filter and becomes an explicit error the integrator sees on their very first test.
src/schemas/orders.js
src/schemas/orders.js// src/schemas/orders.js
import { z } from 'zod';
import { coffeeIdSchema, customerIdSchema, pagination, isoDate } from './common.js';
/** One order item exactly as the client sends it. */
const orderItemSchema = z
.object({
coffeeId: coffeeIdSchema,
quantity: z
.number()
.int('The quantity must be an integer')
.min(1, 'The minimum quantity is 1')
.max(99, 'The maximum quantity per item is 99'),
})
.strict();
/**
* POST /v1/orders
* The client does NOT send prices or a total: the server sets them from the
* catalogue. If we accepted them, anyone could buy at €0.01.
*/
export const createOrderSchema = z
.object({
customerId: customerIdSchema,
items: z
.array(orderItemSchema)
.min(1, 'The order must have at least one item')
.max(50, 'At most 50 items per order'),
})
.strict()
.superRefine((data, ctx) => {
// The same coffee cannot appear in two items: it would be ambiguous both
// when deducting stock and when calculating the total.
const seen = new Set();
data.items.forEach((item, index) => {
if (seen.has(item.coffeeId)) {
ctx.addIssue({
code: 'custom',
path: ['items', index, 'coffeeId'],
message: `The coffee '${item.coffeeId}' is repeated; group the quantities into one item`,
});
}
seen.add(item.coffeeId);
});
});
/** Query parameters of GET /v1/orders (02-06). */
export const orderQuerySchema = z
.object({
customerId: customerIdSchema.optional(),
status: z.enum(['pending_payment', 'paid', 'shipped']).optional(),
dateFrom: isoDate.optional(),
dateTo: isoDate.optional(),
sort: z.string().optional(),
cursor: z.string().optional(), // opaque: it is interpreted in 03-05
limit: pagination.limit,
offset: pagination.offset,
})
.strict()
.refine((d) => !d.dateFrom || !d.dateTo || d.dateFrom <= d.dateTo, {
message: "'dateFrom' must be earlier than or equal to 'dateTo'",
path: ['dateFrom'],
});The most important note in this file is the one on createOrderSchema: the client does not send prices. It is a perfect case of why validation is also design. Accepting priceEuros inside an order item would be a textbook business vulnerability; rejecting it with strict input eliminates it at the root.
- The
validate(schema, source) middleware
validate(schema, source) middlewareA single piece for all three inputs:
// src/middleware/validation.js
/**
* Generic validation middleware.
*
* @param {import('zod').ZodTypeAny} schema Zod schema to apply.
* @param {'body'|'query'|'params'} source Part of the request to validate.
*
* If validation passes, it REPLACES req[source] with the parsed value,
* with the types converted and the default values applied. From then on,
* the controller works with clean data and never checks again.
*/
export function validate(schema, source = 'body') {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
// The code depends on the source: the contract of 02-04 distinguishes
// 'invalid_data' (body) from 'invalid_parameter' (query/path).
const code = source === 'body' ? 'invalid_data' : 'invalid_parameter';
return res.status(400).json({
error: {
code,
message:
source === 'body'
? 'The request body contains validation errors.'
: 'The request parameters contain errors.',
details: toDetails(result.error),
},
});
}
req[source] = result.data;
next();
};
}
/** Translates Zod's issues into the contract's 'details' format. */
function toDetails(error) {
return error.issues.map((issue) => ({
field: issue.path.join('.') || '(body)',
code: translateCode(issue),
message: issue.message,
}));
}
/** Zod's internal codes → the contract's stable codes. */
function translateCode(issue) {
switch (issue.code) {
case 'invalid_type':
return issue.received === 'undefined' ? 'required' : 'wrong_type';
case 'too_small':
return 'below_minimum';
case 'too_big':
return 'above_maximum';
case 'unrecognized_keys':
return 'unknown_field';
case 'invalid_string':
case 'invalid_format':
return 'invalid_format';
case 'invalid_enum_value':
case 'invalid_value':
return 'value_not_allowed';
default:
return 'invalid_value';
}
}Three design decisions worth justifying:
Why req[source] is replaced. After the middleware, req.query.limit is the number 20, not the string '20', and req.body.tastingNotes is [] even if the client never sent it. The controller is left with no conversions and no defaults: all of that happened at the edge. It is the materialisation of "validate at the edge, trust inside".
Why Zod's codes are translated. too_small or unrecognized_keys are implementation details of a library. If we exposed them, upgrading Zod could change our API's contract, and that is unacceptable (02-07). The translation insulates us: switching library would not change a single visible code.
Why the messages are not filtered. Zod's messages are readable and in most cases we have written them ourselves inside the schema. The ones we have not —Zod's own defaults— can be replaced wholesale with a global map; exercise 3 covers it.
A note about Express 5. In Express 4,
req.queryis an ordinary property and can be reassigned. In Express 5 it became a read-only getter, so the assignment fails silently. The portable solution is to store the result in a property of your own (req.validated = { ...req.validated, [source]: result.data }) and read from there in the controller. Since this course uses Express 4, we keep the direct form, which is more readable.
- From
ZodError to the contract's error format
ZodError to the contract's error formatWith the above in place, a request with four failures produces this response:
{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [
{ "field": "name", "code": "below_minimum", "message": "The name needs at least 3 characters" },
{ "field": "roast", "code": "value_not_allowed", "message": "The roast must be 'light', 'medium' or 'dark'" },
{ "field": "priceEuros", "code": "wrong_type", "message": "Expected number, received string" },
{ "field": "stock", "code": "below_minimum", "message": "The stock cannot be negative" }
]
}
}Four failures, one single response. That is the promise of 02-04, and it is what separates a pleasant API from an unbearable one: with "fail on the first error" validation, the integrator would need four attempts and four deployments of their client to discover the same thing.
For nested fields, path.join('.') produces readable paths:
Zod path |
field in details |
|---|---|
['name'] |
name |
['items', 0, 'quantity'] |
items.0.quantity |
[] (error on the whole object) |
(body) |
- The routes, with validation
Now the routes also declare the input contract. src/routes/coffees.js ends up like this:
// src/routes/coffees.js
import { Router } from 'express';
import { coffeeController } from '../controllers/coffees.js';
import { validate } from '../middleware/validation.js';
import {
createCoffeeSchema,
replaceCoffeeSchema,
modifyCoffeeSchema,
coffeeQuerySchema,
coffeeIdParamsSchema,
} from '../schemas/coffees.js';
export const coffeeRoutes = Router();
coffeeRoutes.get('/', validate(coffeeQuerySchema, 'query'), coffeeController.list);
coffeeRoutes.post('/', validate(createCoffeeSchema), coffeeController.create);
coffeeRoutes.get('/:id', validate(coffeeIdParamsSchema, 'params'), coffeeController.get);
coffeeRoutes.put(
'/:id',
validate(coffeeIdParamsSchema, 'params'),
validate(replaceCoffeeSchema),
coffeeController.replace
);
coffeeRoutes.patch(
'/:id',
validate(coffeeIdParamsSchema, 'params'),
validate(modifyCoffeeSchema),
coffeeController.modify
);
coffeeRoutes.delete('/:id', validate(coffeeIdParamsSchema, 'params'), coffeeController.remove);It reads like a specification: for each method and URI, what is validated and who handles it. And the controller slims down remarkably, because every manual check disappears:
// src/controllers/coffees.js (simplified version after validation)
list(req, res) {
// req.query already arrives validated and with the right types: limit and
// offset are numbers, available is a boolean, and there are no unknown
// parameters. There is nothing left to check here.
const { origin, roast, priceMin, priceMax, available, q, sort, fields } = req.query;
const { limit, offset } = req.query;
const sortCriteria = parseSort(sort);
if (sortCriteria.error) {
return res.status(400).json({
error: {
code: 'invalid_parameter',
message: sortCriteria.error,
details: [{ field: 'sort', code: 'value_not_allowed', message: sortCriteria.error }],
},
});
}
const { items, total } = coffeeService.list({
origin,
roast,
priceMinCents: priceMin === undefined ? undefined : eurosToCents(priceMin),
priceMaxCents: priceMax === undefined ? undefined : eurosToCents(priceMax),
available,
q,
sort: sortCriteria,
limit,
offset,
});
const links = buildLinkHeader({ req, limit, offset, total });
if (Object.keys(links).length > 0) res.links(links);
res.status(200).json({
data: items.map(coffeeToRepresentation).map(toCollectionSummary).map((r) => project(r, fields)),
total,
});
},
create(req, res) {
// req.body is already validated: there is not a single 'if' here.
const coffee = coffeeService.create(req.body);
res.set('Location', `/v1/coffees/${coffee.id}`);
res.status(201).json(coffeeToRepresentation(coffee));
},create has gone from twenty lines to three. That is the measurable benefit of moving validation to the edge.
- Invalid requests and their exact responses
# 1. Several failures at once in the body
curl -s -X POST http://localhost:3000/v1/coffees \
-H "Content-Type: application/json" \
-d '{"name":"K","origin":"Kenya","roast":"roasted","priceEuros":"16.75","stock":-3}' | jq{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [
{ "field": "name", "code": "below_minimum", "message": "The name needs at least 3 characters" },
{ "field": "roast", "code": "value_not_allowed", "message": "The roast must be 'light', 'medium' or 'dark'" },
{ "field": "priceEuros", "code": "wrong_type", "message": "Expected number, received string" },
{ "field": "stock", "code": "below_minimum", "message": "The stock cannot be negative" }
]
}
}# 2. Unknown field: strict input
curl -s -X POST http://localhost:3000/v1/coffees \
-H "Content-Type: application/json" \
-d '{"name":"Kenya Nyeri","origin":"Kenya","roast":"medium","priceEuros":16.75,"stock":40,"colour":"red"}' \
| jq '.error.details'[{ "field": "(body)", "code": "unknown_field", "message": "Unrecognized key(s) in object: 'colour'" }]# 3. Unknown query parameter: 'invalid_parameter', not 'invalid_data'
curl -s "http://localhost:3000/v1/coffees?pageSize=20" | jq '.error.code'# 4. Limit out of range: 400, it is NOT clamped to 100
curl -s "http://localhost:3000/v1/coffees?limit=5000" | jq '.error.details[0]'# 5. Badly formatted id: caught in params, without touching the store
curl -s "http://localhost:3000/v1/coffees/1" | jq '.error'{
"code": "invalid_parameter",
"message": "The request parameters contain errors.",
"details": [{ "field": "id", "code": "invalid_format", "message": "The id must look like 'cof_001'" }]
}This last case has more to it than meets the eye. A malformed id now produces 400 invalid_parameter and never reaches the repository. The alternative —letting it through and returning 404 coffee_not_found— would also be defensible, but we chose the 400 because it distinguishes "you mistyped the identifier" from "that coffee does not exist", and it also saves a database query for every junk request. With SQL behind it (03-05), that upfront validation is one more layer against injection too.
# 6. Empty PATCH
curl -s -X PATCH http://localhost:3000/v1/coffees/cof_001 \
-H "Content-Type: application/merge-patch+json" -d '{}' | jq '.error.details[0].message'
- Business validation: why it lives in the service
There are rules a schema cannot check, and confusing them with format validation is the most common conceptual mistake of this lesson:
| Rule | Schema or service? | Why |
|---|---|---|
quantity is an integer ≥ 1 |
Schema | Visible by looking at the request |
coffeeId has the form cof_\d{3} |
Schema | Pure format |
| That coffee exists | Service | It requires querying the store |
| There is enough stock | Service | It depends on state and changes between two requests |
| The order is not already paid | Service | It depends on the state machine |
| The customer may see this order | Service | It depends on identity (03-06) |
The three underlying reasons:
- The schema has no access to the data. Putting a query inside a
refinewould make the schema asynchronous, database-dependent and impossible to reuse for generating documentation. - State changes. Between validating "there is stock" and deducting it, another order can slip in. The check must happen inside the same transaction as the deduction (03-05); doing it at the edge gives a false sense of safety.
- The error code is different. An invalid format is
400 invalid_data; insufficient stock is409 insufficient_stock, a state conflict rather than a writing mistake. They are different families in the catalogue of 02-04.
This is how that validation looks in the order service, written today in the provisional form that 03-07 will turn into throw new ApiError(...):
// src/services/orders.js (added)
import { coffeeRepository } from '../repositories/coffees-memory.js';
export const orderService = {
// ...list and get...
/**
* Checks the business rules of a new order.
* Returns null if everything is fine, or an array of domain error objects.
* In 03-07 this will become a throw of ApiError.
*/
checkItems(items) {
const problems = [];
for (const item of items) {
const coffee = coffeeRepository.findById(item.coffeeId);
if (!coffee) {
problems.push({
code: 'coffee_not_found',
field: `items.${item.coffeeId}`,
message: `The coffee '${item.coffeeId}' does not exist or has been discontinued.`,
});
continue;
}
if (coffee.stock < item.quantity) {
problems.push({
code: 'insufficient_stock',
field: `items.${item.coffeeId}`,
message: `Only ${coffee.stock} units of '${coffee.name}' remain and ${item.quantity} were requested.`,
});
}
}
return problems.length > 0 ? problems : null;
},
};Notice that here too every problem is accumulated before responding. The consistency with schema validation is deliberate: if an order has three items out of stock, the client deserves to hear about all three at once.
- Sanitisation, normalisation and the size limit
Three concepts that get mixed up and that do different things:
| Concept | What it does | Example |
|---|---|---|
| Validation | Accepts or rejects | "roasted" is not a valid roast → 400 |
| Normalisation | Unifies equivalent forms | " colombia " → "Colombia" |
| Sanitisation | Neutralises dangerous content | Escaping HTML before displaying it |
Aroma Store validates and normalises in the schema. Sanitisation is a matter of output context and is not done here: trying to "clean" HTML on the way in produces mutilated data —a customer whose surname is O'Brien should not lose the apostrophe— and a false sense of security. The right thing is to store the text as it is and escape it at the moment of use: prepared statements for SQL (03-05), HTML escaping in the client that renders it. In 04-02 the reasoning is developed in detail.
As for the size limit, we already set it in 03-02:
It is a defence that schema validation cannot give you, because it acts earlier: without a limit, a 500 MB body is parsed entirely into memory and the process dies before Zod sees anything. With limit, Express responds 413. That 413 produces an ugly Express response today; in 03-07 we will turn it into the catalogue's body_too_large.
One final point: validation protects against prototype pollution. A body containing {"__proto__": {"isAdmin": true}} could, combined with a careless Object.assign, modify the prototype of every object in the process. With .strict(), that key is simply an unknown field and the request dies at the edge with a 400.
- Alternatives to Zod and the link with OpenAPI
| Library | Approach | Note |
|---|---|---|
| Zod | Schemas as code, with type inference | The course's choice: no dependencies and very readable |
| Joi | Schemas as code, a veteran | Very mature; no TypeScript type inference |
| Yup | Similar to Joi, popular in forms | Convenient on the client, slightly less so on the server |
| express-validator | Middleware chained onto req |
Very well integrated with Express; the schema is not a reusable object |
| AJV + JSON Schema | The JSON Schema standard, very fast | The only one that validates the OpenAPI schema directly |
That last row points at an important topic. In 02-08 we wrote openapi.yaml as the contract's source of truth, and in this module we have written Zod schemas that say practically the same thing. We have two definitions of the same truth, and two definitions end up diverging: somebody adds a field to the Zod schema and forgets the YAML.
The three ways of resolving it:
- Generate OpenAPI from Zod, with tools such as
zod-to-json-schemaor@asteasolutions/zod-to-openapi. The code rules. - Generate the validators from OpenAPI, with AJV and the contract's JSON Schema. The specification rules; it is the most coherent option with API-first.
- Keep both and verify in CI that the implementation complies with the specification, using contract tests.
The third is the most practical and the one we will see in 05-04, where we will automatically check that every response validates against the published schema. For now it is enough to be aware of the risk: every time you touch a schema in src/schemas/, touch openapi.yaml too. It is exactly the drift we warned about in 02-08.
Common Mistakes and Tips
1. Validating only the body. Query and path parameters are just as manipulable, and ?limit=999999 is an availability problem.
2. Using z.coerce.boolean() for a URL parameter. It turns any non-empty string into true, including 'false'. Use z.enum(['true','false']).transform(...).
3. Putting database queries inside a refine. The schema must not know about the state of the system. That check belongs to the service and often to the same transaction as the write.
4. Returning Zod's internal codes to the client. too_small is a detail of a dependency; if you publish it, upgrading the library forces you to change the contract.
5. Forgetting .strict(). Without it, the object is validated but unknown fields are silently discarded and the strict-input contract stops being honoured.
6. Reusing the POST schema for PATCH. PATCH requires everything to be optional; using the POST one forces the client to resend the whole resource, which is what PUT does.
7. Trusting that the client already validates. Never. The SPA validates for usability; the server validates for correctness.
8. Validating after touching the database. The order in the middleware chain matters: validate goes before the controller, always.
Tip: when you are unsure whether a check belongs to the schema or to the business, ask yourself whether you could answer by looking only at the text of the request. If you need to look something up, it is business and it belongs in the service.
Exercises
Exercise 1
Write the schema createReviewSchema for POST /v1/coffees/:id/reviews. According to the contract: rating is a required integer from 1 to 5, comment is optional text of between 10 and 2000 characters, and no other field is accepted (in particular status, which the server sets to pending_moderation). Add the rule that if there is a comment, it cannot be nothing but whitespace. Show the route with its validation.
Exercise 2
An integrator complains that POST /v1/orders with this body returns 400 and does not understand why:
{
"customerId": "cus_842",
"items": [
{ "coffeeId": "cof_001", "quantity": 2, "priceEuros": 14.50 },
{ "coffeeId": "cof_001", "quantity": 1 }
],
"totalEuros": 43.50
}List every failure createOrderSchema will detect, write the complete response the API returns, and explain to the integrator why rejecting priceEuros and totalEuros is not an annoyance but a protection.
Exercise 3
Zod's default messages are written in the library's own voice ("Expected number, received string"), which clashes with the wording style of the rest of our messages and cannot be adapted for consumers who ask for another language with Accept-Language. Propose a solution that replaces those default messages without having to write a message by hand in every field of every schema, and implement it alongside translateCode/toDetails. Comment on the advantage of having a stable code as well as a message.
Solutions
Solution 1
// src/schemas/reviews.js
import { z } from 'zod';
export const createReviewSchema = z
.object({
rating: z
.number()
.int('The rating must be an integer')
.min(1, 'The minimum rating is 1')
.max(5, 'The maximum rating is 5'),
comment: z
.string()
.trim()
.min(10, 'The comment needs at least 10 characters')
.max(2000, 'The comment cannot exceed 2000 characters')
.optional(),
})
.strict();The .trim() before .min(10) solves the whitespace-only comment rule all by itself: " " becomes the empty string and fails the minimum. It is more elegant than a refine, and it shows that the order of the chained calls in Zod carries meaning.
// src/routes/coffees.js
import { createReviewSchema } from '../schemas/reviews.js';
coffeeRoutes.post(
'/:id/reviews',
validate(coffeeIdParamsSchema, 'params'),
validate(createReviewSchema),
reviewController.create
);About status: it does not appear in the schema on purpose. With .strict(), a client sending "status": "published" gets 400 unknown_field and cannot bypass moderation. It is the same pattern as priceEuros in order items: fields the server sets are not accepted on input, they are rejected.
Solution 2
Failures detected, three in total:
items.0.priceEuros— unknown field inside the item:orderItemSchemais.strict()and only acceptscoffeeIdandquantity.totalEuros— unknown field at the root:createOrderSchemais.strict()and only acceptscustomerIdanditems.items.1.coffeeId—cof_001appears twice, caught by thesuperRefine.
The API's response:
{
"error": {
"code": "invalid_data",
"message": "The request body contains validation errors.",
"details": [
{ "field": "items.0", "code": "unknown_field", "message": "Unrecognized key(s) in object: 'priceEuros'" },
{ "field": "(body)", "code": "unknown_field", "message": "Unrecognized key(s) in object: 'totalEuros'" },
{ "field": "items.1.coffeeId", "code": "invalid_value", "message": "The coffee 'cof_001' is repeated; group the quantities into one item" }
]
}
}Explanation for the integrator: the prices and the total are calculated by the server from the catalogue at the moment the order is created, and that is why the API does not accept them as input. If it did, anyone could send priceEuros: 0.01 and buy speciality coffee for a penny; on top of that, a totalEuros sent by the client might not match the sum of the items, and then someone would have to decide which of the two numbers is the right one. Rejecting them removes a fraud and an ambiguity in one stroke. The 201 response does return priceEuros per item and totalEuros, already calculated and frozen, which is what the client needs in order to display them.
About the repeated coffee: the contract prefers a single item per coffee with the quantities grouped, because two items for the same product make the stock deduction ambiguous and complicate partial returns.
Solution 3
Zod allows a global error map that applies to every schema, without touching a single field:
// src/schemas/messages.js
import { z } from 'zod';
/**
* Global error map: replaces Zod's default messages with our own wording.
* It is installed once at start-up and affects every schema.
* If a field defines its own message, that one takes precedence.
*/
const friendlyMessageMap = (issue, context) => {
switch (issue.code) {
case 'invalid_type':
return issue.received === 'undefined'
? { message: 'This field is required' }
: { message: `Expected ${issue.expected} but received ${issue.received}` };
case 'too_small':
return { message: `The minimum accepted value is ${issue.minimum}` };
case 'too_big':
return { message: `The maximum accepted value is ${issue.maximum}` };
case 'unrecognized_keys':
return { message: `Unrecognised fields: ${issue.keys.join(', ')}` };
default:
return { message: context.defaultError };
}
};
export function installFriendlyMessages() {
// The exact name of this function varies between major versions of Zod
// (setErrorMap / config); the idea is the same: one global map.
z.setErrorMap(friendlyMessageMap);
}It is installed once, in src/app.js, before mounting the routes:
Now the failure from the earlier example returns "Expected number but received string", written in our voice and ready to be swapped for a translated map when the API starts honouring Accept-Language on its messages, without a single field of any schema having been touched.
The advantage of having code as well as message: the message is for humans —it can change, be translated or be rewritten so that it reads better, and none of that breaks anyone. The code is for machines: a client can write if (detail.code === 'required') and trust that it will not change, because it is part of the contract and subject to the versioning rules of 02-07. Separating the two lets you improve the wording of the messages on any given Tuesday without publishing a new version of the API. It is the same principle by which enums are in snake_case and never translated (02-05).
Conclusion
The API no longer accepts rubbish. The schemas in src/schemas/ are now the executable definition of what comes in: types, ranges, identifier formats, enums, ISO-8601 dates, prices with two decimals and lists with a maximum length; with .strict() to honour the strict input of 02-05, z.coerce to convert the query parameters that always arrive as text, default() so that the controller receives the contract's values already applied, and partial() so that PATCH demands exactly what it should. A single middleware, validate(schema, source), applies all of that to body, query and params, replaces the input with the parsed value and translates the ZodError into the contract's format: 400 invalid_data for the body, 400 invalid_parameter for the parameters, and every failure at once in details, each with its field, its stable code and its message.
Just as important is what we have not put into the schemas. Whether a coffee exists, the available stock, an order that is already paid or the permission to see a resource all depend on the state of the system, change between two requests and carry error codes from another family (409, not 400). That validation lives in the service, and some of its checks will have to happen inside the same transaction as the write.
And that is exactly where we are heading. Everything built so far rests on two in-memory arrays that empty out with each restart of node --watch, that cannot handle serious queries and that cannot guarantee that deducting stock across three items is an atomic operation. In 03-05, Persistence and the Data Access Layer, we will replace that store with SQLite via better-sqlite3 behind the repository pattern: a SQL schema with an integer price_cents, versioned migrations and seed data, prepared statements that shut the door on injection, safe dynamic queries for the filters of 02-06, transactions for creating an order while deducting stock, optimistic concurrency control with version_conflict and genuine cursor pagination. And we will do it without touching a single line of the controllers or the services, which is the promise we made in 03-01 when we separated the layers.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
