Skip to main content
Node.js · lesson 6 of 20
Node.js beginner Lesson 6 of 20

Validating Requests with Zod

Write a Zod schema for a POST body, turn failures into 400 responses with per-field errors, and use the parsed, trimmed output instead of the raw body.

By SkillByExample · Updated

nodejs express zod validation

A Zod schema is both a runtime validator and, in TypeScript, a type — but even in plain JavaScript, safeParse turns an unstructured req.body into a predictable shape or a clear list of what’s wrong with it.

A Validation Middleware

// validate.js
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});

export function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: "validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}
// server.js
import express from "express";
import { CreateUserSchema, validateBody } from "./validate.js";

const app = express();
app.use(express.json());

const users = [{ id: 1, name: "Alice", email: "alice@example.com" }];

app.get("/users", (req, res) => {
  res.json(users);
});

app.post("/users", validateBody(CreateUserSchema), (req, res) => {
  // req.body is now CreateUserSchema's parsed output: trimmed name, a real email.
  const user = { id: users.length + 1, ...req.body };
  users.push(user);
  res.status(201).json(user);
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "internal error" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

node server.js & returns before Express has actually bound the port, so the loop below retries every 200 ms until a real request succeeds — and because the server’s own listening on ... line prints while that loop is still spinning, it shows up under the wait step in the output, not the line that started the process.

$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"  Bob  ","email":"bob@example.com"}'
{"id":2,"name":"Bob","email":"bob@example.com"}
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
{"error":"validation failed","details":{"name":["Too small: expected string to have >=2 characters"],"email":["Invalid email address"]}}
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Zoe","email":"zoe@example.com","age":-5}'
{"error":"validation failed","details":{"age":["Too small: expected number to be >=0"]}}
$ kill $!

" Bob " came back as "Bob" with no leading or trailing spaces — validateBody replaced req.body with result.data, Zod’s parsed output, not the original request body, and .trim() in the schema already ran by the time the route handler reads req.body.name. The second request fails two checks at once, and z.flattenError(...).fieldErrors groups both messages under their own field names in one response, rather than stopping at the first failure. The third shows age validated independently from name/email — it’s .optional(), so leaving it out entirely would have passed, but providing a negative number doesn’t. The final kill $! stops the server this example started — $! still refers to it because nothing else was backgrounded in between.

Practice

1. Add a role field to CreateUserSchema that must be either "admin" or "member", using z.enum.

Solution
// validate.js (added field)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
  role: z.enum(["admin", "member"]),
});

export function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: "validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

validate.js changed, so this needs a fresh server process picking up the new schema — the one from the main example was already killed by its own kill $! step:

$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Nora","email":"nora@example.com","role":"owner"}'
{"error":"validation failed","details":{"role":["Invalid option: expected one of \"admin\"|\"member\""]}}
$ kill $!

"owner" isn’t one of the two allowed literal strings, so z.enum rejects it with a message listing exactly what’s accepted, independent of where it fits alphabetically.

2. Change the middleware to return 422 Unprocessable Entity instead of 400 for a validation failure, and explain when that distinction actually matters to an API consumer.

Solution
// validate.js (status code changed)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});

export function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(422).json({
        error: "validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"nope"}'
HTTP/1.1 422 Unprocessable Entity
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 136
ETag: W/"88-hLApVwVkHy7KchfeZN+FIfEMmBc"
Date: Thu, 29 Oct 2026 20:44:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"validation failed","details":{"name":["Too small: expected string to have >=2 characters"],"email":["Invalid email address"]}}
$ kill $!

400 means the request itself was malformed (bad JSON, missing content-type); 422 means the request was syntactically fine JSON but semantically invalid against the schema. Some API style guides pick one convention and stick to it everywhere — the distinction only matters if a client actually branches on it.

3. Write a schema for a PATCH body where every field is optional, using CreateUserSchema.partial(), and confirm an empty object {} passes.

Solution
// validate.js (added schema)
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().trim().min(2),
  email: z.email(),
  age: z.number().int().min(0).optional(),
});

export const UpdateUserSchema = CreateUserSchema.partial();

export function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: "validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}
// check-partial.js
import { UpdateUserSchema } from "./validate.js";

console.log(UpdateUserSchema.safeParse({}).success);
console.log(UpdateUserSchema.safeParse({ name: "X" }).success);
$ node check-partial.js
true
false

{} passes because every field is now optional. { name: "X" } still fails, because .partial() only makes fields optional — when name IS present, it still has to pass min(2), and "X" is one character.

4. Add a schema-level check with .refine() so CreateUserSchema rejects a name equal to "admin" (case-insensitive), with a custom error message.

Solution
// validate.js (added refine)
import { z } from "zod";

export const CreateUserSchema = z
  .object({
    name: z.string().trim().min(2),
    email: z.email(),
    age: z.number().int().min(0).optional(),
  })
  .refine((data) => data.name.toLowerCase() !== "admin", {
    error: "name 'admin' is reserved",
    path: ["name"],
  });

export function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: "validation failed",
        details: z.flattenError(result.error).fieldErrors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}
$ node server.js &
$ until curl -s -o /dev/null http://localhost:3000/users; do sleep 0.2; done
listening on http://localhost:3000
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Admin","email":"admin@example.com"}'
{"error":"validation failed","details":{"name":["name 'admin' is reserved"]}}
$ kill $!

.refine() runs after every field already passed its own check, so it can compare across fields or apply a rule z.string() alone can’t express — path: ["name"] is what makes the custom message show up under name in fieldErrors instead of under a generic top-level key.

Frequently Asked Questions

Why validate request bodies at runtime if JavaScript already checks types?
JavaScript doesn't check types at a function boundary the way a compiler would — req.body is whatever JSON the client sent, with no guarantee it matches what the route expects. A missing field, a string where a number belongs, or a malformed email all pass straight through until something validates the shape at runtime.
What does z.flattenError do?
It takes Zod's detailed issue list and groups messages by field name, producing a fieldErrors object like { email: ["Invalid email address"] }. That shape maps directly onto form fields on a client, which is more useful to an API consumer than Zod's raw nested issue array.
Does safeParse mutate req.body?
No — safeParse returns a result object with the parsed data on success; nothing is mutated until the middleware itself does req.body = result.data. That line matters: after it runs, req.body is the schema's output (trimmed, coerced), not the original request body.
Why does trim() happen in the schema instead of in the route handler?
Putting it in the schema means every route using CreateUserSchema gets the same trimming for free, and the route handler can trust that req.body.name is already clean — it never needs to remember to call trim() itself.
What Zod version does this lesson use?
Zod 4.6.7, on Express 5.3.0 and Node v24.18.1. Zod 4 changed several error-message and API details from Zod 3, including flattenError replacing the older .flatten() method.