NodeJSValidation with Joi

Validation with Joi

Joi is a mature, expressive schema-description and validation library. You build a schema with a fluent, chainable API, then validate data against it. Joi predates the TypeScript-first wave and remains popular for its breadth of built-in rules and its framework-agnostic design — it validates any JavaScript value, not just Express requests. This page covers building schemas, the validation result, options, and integrating Joi as Express middleware.

Setup and a first schema

Bash
npm install joi

JS
import Joi from '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(150),
  role:  Joi.string().valid('user', 'admin').default('user'),
  tags:  Joi.array().items(Joi.string()).max(10),
})
Chainable, fluent rules read like a spec
Each field is a Joi type (`Joi.string()`, `Joi.number()`…) with constraints chained on (`.min()`, `.email()`, `.required()`). The schema reads almost like prose describing the valid shape. Fields are **optional by default** — you opt into required with `.required()`, which is the opposite of some other libraries, so it's a common surprise.
Validating — the result object

JS
const { error, value } = userSchema.validate(req.body)

if (error) {
  // error.details is an array of { message, path, type }:
  const details = error.details.map((d) => ({
    field: d.path.join('.'),
    message: d.message,
  }))
  return res.status(422).json({ error: 'Validation failed', details })
}

// 'value' is the validated + coerced + defaulted data — use THIS:
createUser(value)
`validate` stops at the FIRST error by default — pass `abortEarly: false`
By default Joi returns as soon as it hits the first invalid field, so the client sees only one error at a time — frustrating for forms. Pass `{ abortEarly: false }` to collect **all** errors in one pass and return them together. This is the single most important option to set for user-facing validation.
Key validation options

JS
const { error, value } = userSchema.validate(req.body, {
  abortEarly: false,    // report ALL errors, not just the first
  stripUnknown: true,   // remove keys not in the schema (anti mass-assignment)
  convert: true,        // coerce types: "42" → 42, "true" → true (default on)
  presence: 'optional', // default presence for all keys
})

Option

Effect

abortEarly: false

Collect every error, not just the first

stripUnknown: true

Drop unknown keys — defends against mass assignment

allowUnknown: true

Permit (keep) unknown keys instead of erroring

convert: true

Type coercion ("5" → 5); on by default

Unknown keys: error, strip, or allow — choose deliberately
By default Joi **errors** on keys not in the schema. `stripUnknown: true` silently removes them (good for safely ignoring extra fields while preventing [mass assignment](/nodejs/validation-intro)); `allowUnknown: true` keeps them. Decide intentionally — silently allowing unknown keys can let a client smuggle `isAdmin` through; stripping is usually the safe default for request bodies.
Reusable validation middleware

JS
// Factory: validate a request part against a Joi schema.
const validate = (schema, part = 'body') => (req, res, next) => {
  const { error, value } = schema.validate(req[part], {
    abortEarly: false,
    stripUnknown: true,
  })
  if (error) {
    return res.status(422).json({
      error: 'Validation failed',
      details: error.details.map((d) => ({
        field: d.path.join('.'),
        message: d.message,
      })),
    })
  }
  req[part] = value           // replace with cleaned, coerced data
  next()
}

app.post('/users', validate(userSchema), createUserHandler)
app.get('/users',  validate(listQuerySchema, 'query'), listUsersHandler)
Same middleware pattern as every validator
Wrap Joi in a [custom middleware](/nodejs/custom-middleware) factory so handlers receive already-validated data on `req.body`/`req.query`. This mirrors the pattern shown for [Zod](/nodejs/zod) and what [express-validator](/nodejs/express-validator) does natively — the library differs, the integration shape is the same.
Powerful conditional & cross-field rules

JS
const schema = Joi.object({
  password: Joi.string().min(8).required(),
  // Must match 'password':
  confirm: Joi.string().valid(Joi.ref('password')).required()
    .messages({ 'any.only': 'Passwords must match' }),

  // Required only when 'type' is 'business':
  type: Joi.string().valid('personal', 'business'),
  taxId: Joi.when('type', {
    is: 'business',
    then: Joi.string().required(),
    otherwise: Joi.forbidden(),
  }),
}).with('email', 'name')   // if email present, name must be too
Joi shines at cross-field and conditional logic
Joi's `.ref()`, `.when()`, `.with()`/`.without()`, and custom `.messages()` express relationships *between* fields — password confirmation, "required only if", mutually-exclusive fields — declaratively. This expressiveness, plus a huge catalogue of built-in rules (dates, URIs, IP, credit cards via extensions), is Joi's main draw over leaner libraries.
Joi vs the alternatives

Joi

Zod

express-validator

Style

Fluent schema

Fluent schema

Per-field middleware chain

TypeScript types

Add-on/manual

Inferred from schema

Manual

Scope

Any JS value

Any JS value

Express requests only

Strength

Breadth of rules, conditionals

Type inference

Tight Express integration

Pick Joi for rich rules in plain JS; Zod for TS projects
Joi is an excellent choice for JavaScript projects that need its deep rule set and conditional power, and it validates anything (config, queue messages, not just HTTP). If you're on TypeScript and want your validation schema to also *be* your static type, [Zod](/nodejs/zod) is usually the better fit. They solve the same problem with different ergonomics — there's no wrong choice, just a contextual one.
Next
The TypeScript-first validator with type inference: [Validation with Zod](/nodejs/zod).