Menu
CoddyTech

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 icon

Challenge

Easy

This 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.

  1. In routes/tasks.js, import z from zod and write a schema called Task:
    • title: a string, trimmed, 1 to 100 characters after trimming.
    • done: true or false (z.boolean()).
    • The schema is strict, so a field it does not list, like color, is refused.

    Delete the supplied titleError helper (the course's version of the validation from Validating Input).

  2. Make two versions from Task with .partial(). POST's version makes only done optional. PATCH's version makes both fields optional. PUT uses Task itself, so it needs both fields.
  3. In POST, PATCH and PUT, check req.body with safeParse. When the check fails, respond with status 400 and zod's list of problems under error, made with z.flattenError(result.error). For { "title": 123 } it looks like this:
    { "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } }
  4. When the check passes, save from result.data, not req.body: it holds the trimmed title. In POST, a body without done creates the task with "done": false (a missing field is undefined in result.data, and ?? gives a fallback). PUT still keeps id and createdAt from 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

Practice on your own: Express playground