Skip to content
Express.js ex validation 4 min read

Schema Validation with Joi

Joi is a schema-description language and validator for JavaScript objects. Instead of scattering if checks across your handlers, you declare the exact shape a request should have — types, required fields, ranges, formats — as a single schema object, then ask Joi to check incoming data against it. In Express this pairs naturally with middleware: validate req.body, req.query, or req.params before the controller runs, and reject anything that doesn’t fit with a clean 400. Joi also coerces and strips data, so what reaches your business logic is already normalized.

Installing and importing Joi

Joi ships as a standalone package with no Express dependency. Install it alongside Express:

npm install express joi

Modern Joi (v17+) exports a single object you compose schemas from. Each builder method (Joi.string(), Joi.number(), Joi.object()) returns a new immutable schema, so chaining never mutates shared state.

const Joi = require("joi");

const userSchema = Joi.object({
  name: Joi.string().min(2).max(100).required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(0).max(120),
  role: Joi.string().valid("user", "admin").default("user"),
});

This schema says: name is a required string of 2–100 characters, email is a required valid address, age is an optional non-negative integer up to 120, and role must be one of two literals, defaulting to "user" when omitted.

Validating a request body

Call schema.validate(value) to check a value. It returns an object with two keys: error (a ValidationError, or undefined if valid) and value (the coerced, defaulted result). Joi never throws here — you branch on error yourself.

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

app.post("/users", async (req, res) => {
  const { error, value } = userSchema.validate(req.body);

  if (error) {
    return res.status(400).json({
      error: "Validation failed",
      details: error.details.map((d) => ({
        field: d.path.join("."),
        message: d.message,
      })),
    });
  }

  // `value` is coerced and includes defaults — use it instead of req.body
  const user = await db.users.insert(value);
  res.status(201).json(user);
});

app.listen(3000);

Sending POST /users with { "name": "A", "email": "nope" } produces:

Output:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Validation failed",
  "details": [
    { "field": "name", "message": "\"name\" length must be at least 2 characters long" }
  ]
}

Notice only one error came back. By default Joi stops at the first failure — see the next section to change that.

Controlling behavior: abortEarly and stripUnknown

validate() accepts an options object as a second argument. Two options matter most for HTTP APIs.

OptionDefaultEffect
abortEarlytrueWhen false, Joi reports every failing field instead of stopping at the first.
stripUnknownfalseWhen true, keys not declared in the schema are removed from value.
converttrueCoerces compatible types, e.g. the string "42" to the number 42.
allowUnknownfalseWhen true, unknown keys are allowed through instead of erroring.

For request validation you almost always want abortEarly: false (so the client sees all problems in one round trip) and stripUnknown: true (so extra fields can’t sneak into your model — a mass-assignment defense).

const { error, value } = userSchema.validate(req.body, {
  abortEarly: false,
  stripUnknown: true,
});

Tip: convert defaults to true, which is exactly what you want for query strings — every value in req.query arrives as a string, and Joi.number() will coerce "25" into 25 for you. Disable it only when you need strict type checking.

A reusable validate(schema) middleware

Writing the validate-and-branch block in every handler gets repetitive. Factor it into a higher-order middleware that takes a schema and the request property to check, and returns an Express middleware function.

function validate(schema, property = "body") {
  return (req, res, next) => {
    const { error, value } = schema.validate(req[property], {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      return res.status(400).json({
        error: "Validation failed",
        details: error.details.map((d) => ({
          field: d.path.join("."),
          message: d.message,
        })),
      });
    }

    req[property] = value; // replace with the cleaned, coerced value
    next();
  };
}

Now routes read declaratively, with the schema as a gatekeeper before the controller:

const express = require("express");
const router = express.Router();

const listQuerySchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(20),
});

router.post("/users", validate(userSchema), async (req, res) => {
  const user = await db.users.insert(req.body);
  res.status(201).json(user);
});

router.get("/users", validate(listQuerySchema, "query"), async (req, res) => {
  const { page, limit } = req.query; // already numbers, with defaults applied
  const users = await db.users.list({ page, limit });
  res.json(users);
});

module.exports = router;

Warning: In Express 5, req.query is a read-only getter, so req.query = value throws. Either mutate the existing object’s keys, or attach the cleaned result to a custom property like req.validatedQuery and read from that downstream. Body and params remain writable.

Best Practices

  • Define each route’s schema once as a named constant and reuse it — schemas are immutable, so they’re safe to share.
  • Always pass abortEarly: false for HTTP validation so clients receive every error in a single response.
  • Use stripUnknown: true to drop undeclared fields and prevent mass-assignment into your models.
  • Read from the returned value (or the reassigned req[property]), not the raw input — it carries coercions and defaults.
  • Validate req.query and req.params with their own schemas, not just req.body; remember query values arrive as strings.
  • Wrap validation in a generic validate(schema, property) middleware so route definitions stay declarative and consistent.
  • Map error.details to a stable { field, message } shape so your API contract doesn’t leak Joi internals.
Last updated June 14, 2026
Was this helpful?