Skip to content

Joi Validation with Express

Joi is a schema description and validation library, popular for defining an object's exact shape once and validating request data against it in a single call. This lesson covers integrating Joi into an Express app.

Defining a Joi Schema

Instead of chaining individual field validators like express-validator, Joi describes an entire object's shape as one schema, then validates a value against it in one call, returning either the validated (and optionally sanitized) value, or a detailed error.

This schema-first approach is popular because the same schema definition doubles as clear documentation of exactly what a valid request body looks like.

const Joi = require('joi');

const createUserSchema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(0).required(),
  role: Joi.string().valid('admin', 'user').default('user'),
});

app.post('/users', (req, res) => {
  const { error, value } = createUserSchema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details[0].message });
  }
  res.status(201).json(value);
});

.validate() returns both error (if invalid) and value (the validated, defaulted, and optionally stripped result).

Common Joi Schema Building Blocks

Joi.string().min(3).max(50).required()
Joi.number().integer().min(0)
Joi.boolean()
Joi.array().items(Joi.string())
Joi.object({ ... })
Joi.string().valid('admin', 'user')
  • .required() marks a field as mandatory; fields are optional by default.
  • .default(value) fills in a value when the field is missing.
  • .valid(...) restricts a field to a specific set of allowed values (an enum).
  • Nested objects and arrays of objects are described with Joi.object({...}) and Joi.array().items(...).

Joi Validation Cheatsheet

The core methods you'll use when building Joi schemas.

Rule Example Meaning
Required Joi.string().required() Field must be present
Email format Joi.string().email() Must look like a valid email
Integer range Joi.number().integer().min(0).max(120) Whole number within bounds
Enum Joi.string().valid('admin', 'user') Must be one of the listed values
Default value Joi.string().default('user') Fills in when field is absent
Nested object Joi.object({ city: Joi.string() }) Validates a nested shape

Reusable Joi Validation Middleware

A small generic middleware factory lets you reuse the same validation wiring across every route, only the schema changes per endpoint.

function validateBody(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, { abortEarly: false });
    if (error) {
      return res.status(400).json({
        errors: error.details.map((d) => d.message),
      });
    }
    req.body = value;
    next();
  };
}

app.post('/users', validateBody(createUserSchema), controller.createUser);

abortEarly: false collects every validation error at once instead of stopping at the first one.

Validating Route Params and Query Strings

The same validateBody-style pattern works for req.params and req.query, just target a different property of req.

const idParamSchema = Joi.object({
  id: Joi.string().pattern(/^\d+$/).required(),
});

function validateParams(schema) {
  return (req, res, next) => {
    const { error } = schema.validate(req.params);
    if (error) return res.status(400).json({ error: error.details[0].message });
    next();
  };
}

app.get('/users/:id', validateParams(idParamSchema), controller.getUser);

Common Mistakes

  • Forgetting { abortEarly: false } when you want every validation error returned at once.
  • Not using the sanitized value returned by .validate(), and instead re-reading the raw, unvalidated req.body.
  • Defining the same schema shape inline in multiple route files instead of exporting shared schemas.
  • Using Joi for both request validation and response shaping, mixing two different concerns.

Key Takeaways

  • Joi describes an object's entire shape as one schema, validated in a single call.
  • .validate() returns both an error and a sanitized/defaulted value.
  • A small reusable middleware factory keeps the wiring consistent across routes.
  • The same pattern validates req.body, req.params, or req.query by targeting different properties.

Pro Tip

Always assign req.body = value after a successful Joi validation, this ensures your route handler works with the sanitized and defaulted data, not the original raw input.