Request Validation

intermediate express validation zod joi

Never trust the client. Ever. Validation is the wall between “user typed bad data” and “production database has nullable columns full of "undefined" strings”. Express gives us zero validation out of the box — we pick a library.

In simple language: validation says “is this request shaped the way I expect?” before our handler touches it. Sanitization goes further — it cleans up the input (trims whitespace, strips HTML, normalizes emails).

The three contenders

express-validator
Chain-based middleware
Express-native, sanitize built-in, verbose
Zod
TypeScript-first schemas
Infers TS types from schema, modern API
Joi
Battle-tested, OG
Came from Hapi, rich rule set, no TS inference

express-validator

The Express-native option. Chain validators per field, then check validationResult in the handler.

npm install express-validator
import { body, validationResult } from "express-validator";

app.post(
  "/users",
  body("email").isEmail().normalizeEmail(),
  body("password").isLength({ min: 8 }).withMessage("8 chars min"),
  body("age").optional().isInt({ min: 13, max: 120 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // req.body is sanitized in place
    createUser(req.body);
    res.status(201).end();
  }
);

normalizeEmail() is sanitization — it lowercases, strips dots from Gmail, etc. That’s the “extra” express-validator brings.

Zod

The hot pick for TypeScript projects. Schemas double as TypeScript types — define once, get both runtime checking and compile-time safety.

npm install zod
import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email().toLowerCase().trim(),
  password: z.string().min(8),
  age: z.number().int().min(13).max(120).optional(),
});

// type CreateUser = z.infer<typeof CreateUserSchema>;

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({ errors: result.error.flatten() });
    }
    req.body = result.data; // parsed + transformed
    next();
  };
}

app.post("/users", validate(CreateUserSchema), (req, res) => {
  // req.body is now typed as CreateUser
  createUser(req.body);
  res.status(201).end();
});

The killer feature is z.infer — your schema IS your type. No drift between them, ever.

Joi

The veteran. Used by Hapi, still widely deployed. Same idea as Zod but predates it and has no TS inference.

npm install joi
import Joi from "joi";

const schema = Joi.object({
  email: Joi.string().email().lowercase().required(),
  password: Joi.string().min(8).required(),
  age: Joi.number().integer().min(13).max(120),
});

app.post("/users", (req, res) => {
  const { error, value } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(400).json({ errors: error.details });
  }
  createUser(value);
  res.status(201).end();
});

Validating query and params, not just body

Don’t forget — query strings and URL params are user input too.

import { z } from "zod";

const ListUsersQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().trim().max(50).optional(),
});

app.get("/users", (req, res) => {
  const query = ListUsersQuery.parse(req.query);
  // query.page is a real number now, not "2"
  res.json(listUsers(query));
});

z.coerce.number() is gold here — query strings are always strings, so we coerce "5"5.

Sanitization vs validation

The only difference is: validation rejects, sanitization transforms.

  • body("email").isEmail() → reject if not an email
  • body("email").normalizeEmail() → lowercase, strip Gmail dots
  • body("comment").trim().escape() → strip whitespace, escape <script> tags

Don’t rely on sanitization alone for XSS — output-encode at render time. But it’s a useful belt-and-suspenders.

Which to pick?

  • TypeScript project? Zod. The type inference is too good to skip.
  • JavaScript-only Express app? express-validator or Joi, taste preference.
  • Already using a framework that ships Joi (Hapi-style)? Stay with it.

My default for new code: Zod. The schema-as-type trick eliminates a whole category of “the DB has a string but the type says number” bugs.

Centralize it

Don’t sprinkle validation across handlers. Build a validate(schema) middleware once, then every route is one line:

app.post("/users", validate(CreateUserSchema), createUserHandler);
app.patch("/users/:id", validate(UpdateUserSchema), updateUserHandler);

Clean handlers, schemas live next to the route, no duplication.