Schemas with zod
Part of the REST APIs section of Coddy's Node.js journey. Lesson 11 of 25.
Hand checks grow with every field and every route. A zod schema describes the shape once, and safeParse tests a value against it:
import { z } from 'zod';
const Event = z.object({
name: z.string().trim().min(1).max(80),
seats: z.number().int().min(1),
}).strict();
const result = Event.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: z.flattenError(result.error) });
}
// result.data holds the checked body, with name trimmed.strict() refuses fields the schema does not list. Event.partial() makes every field optional, which is what a PATCH needs. Event.partial({ seats: true }) makes only seats optional, and const { seats = 1 } = result.data uses 1 when it is missing. z.flattenError(result.error) turns the problems into { formErrors, fieldErrors }: formErrors holds problems with the whole body, like an unknown field, and fieldErrors holds the problems of each field. Event.parse does the same check but throws on a bad value, and a throw in a route becomes a 500.
Challenge
EasyThis step opens the course’s prepared task tracker API project, rather than your project files from Validating Input (including routes/tasks.js). Your earlier work stays in its own lesson. Read the supplied files before editing routes/tasks.js. This copy contains task CRUD routes and hand-written input validation.
Your hand-written checks grow with every field and every route. Describe a task once with a zod schema, and let all three routes that take a body use it.
- In
routes/tasks.js, importzfromzodand write a schema calledTask:title: a string, trimmed, 1 to 100 characters after trimming.done:trueorfalse(z.boolean()).- The schema is strict, so a field it does not list, like
color, is refused.
Delete the supplied
titleErrorhelper (the course's version of the validation from Validating Input). - Make two versions from
Taskwith.partial(). POST's version makes onlydoneoptional. PATCH's version makes both fields optional. PUT usesTaskitself, so it needs both fields. - In POST, PATCH and PUT, check
req.bodywithsafeParse. When the check fails, respond with status 400 and zod's list of problems undererror, made withz.flattenError(result.error). For{ "title": 123 }it looks like this:{ "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } } - When the check passes, save from
result.data, notreq.body: it holds the trimmed title. In POST, a body withoutdonecreates the task with"done": false(a missing field isundefinedinresult.data, and??gives a fallback). PUT still keepsidandcreatedAtfrom the stored task.
Try it: POST { "title": " Order new chairs ", "color": "red" } to /api/tasks. You get 400, and formErrors says Unrecognized key: "color". Send it again without color: you get 201 with "title": "Order new chairs" (no spaces) and "done": false. PATCH /api/tasks/2 with { "done": "yes" } returns 400 with a done entry in fieldErrors. PUT /api/tasks/2 with { "title": "Book the big room", "done": false } returns 200, with task 2's old createdAt.
Try it yourself
import { Router } from 'express';
import { nextId, tasks } from '../store.js';
const router = Router();
// Why a title cannot be stored, or null when it can.
const titleError = (title) => {
if (typeof title !== 'string' || title.trim() === '' || title.length > 100) {
return 'title must be a string of 1 to 100 characters';
}
return null;
};
router.get('/', (req, res) => {
res.json(tasks);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
});
router.post('/', (req, res) => {
const { title } = req.body ?? {};
const problem = titleError(title);
if (problem) {
return res.status(400).json({ error: problem });
}
const task = { id: nextId(), title, done: false, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title !== undefined && titleError(title)) {
return res.status(400).json({ error: titleError(title) });
}
if (done !== undefined && typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be true or false' });
}
if (title !== undefined) task.title = title;
if (done !== undefined) task.done = done;
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title === undefined || done === undefined) {
return res.status(400).json({ error: 'PUT needs every field: title and done' });
}
const { id, createdAt } = tasks[index];
tasks[index] = { id, title, done, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
All lessons in REST APIs
1Resources and Verbs
Resources and URLsCreating with POST4Browsers, Versions and Config
What Is an OriginAllowing Another OriginCORSA Page That Calls the API2Validation and Errors
Validating InputPractice on your own: Express playground