LearnFrenzy
Sign in Take a free test

Building a REST API with Node.js · Lesson 11 of 30

REST API Request Validation with Zod

6 min read Updated Nov 8, 2026 Saurabh Samir

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);

Real responses

$ 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. Use req.valid, never the raw body.
  • An invalid request lists every problem, including the unexpected isAdmin field 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?

OptionBehaviourUse for
.strict()unknown fields are an errorsensitive writes – catches typos and attempts to set fields like role
default (strip)unknown fields are silently droppedtolerant APIs where clients may send extra data
.passthrough()unknown fields are keptrarely – 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

  1. Write a schema for a signup: email, password of 12+ characters, and an optional referralCode of 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);

    Real response

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

Next lesson

Search LearnFrenzy

Esc

Quick links