Skip to content

Validation (Joi) — Practical

import Joi from 'joi';
const addressSchema = Joi.object({
street: Joi.string().trim().required(),
city: Joi.string().trim().required(),
zip: Joi.string().pattern(/^\d{5}$/).required(),
});
const baseUser = Joi.object({
email: Joi.string().email().lowercase().required().label('email'),
password: Joi.string().min(12).required().strip(), // stripped from output
age: Joi.number().integer().min(18).optional().default(18),
bio: Joi.string().max(500).allow(null, ''), // null OR empty string OK
address: addressSchema, // nested reuse
});
// Extend (e.g. admin variant)
const adminUser = baseUser.keys({ role: Joi.string().valid('admin').required() });
// Merge two same-typed schemas (rules union)
const audited = baseUser.concat(Joi.object({ createdBy: Joi.string().required() }));
// Pull a sub-schema (PATCH endpoint reuses just the address piece)
const addressOnly = baseUser.extract('address');

Alternatives (any-of / one-of / conditional)

Section titled “Alternatives (any-of / one-of / conditional)”
// any-of: ID can be UUID or numeric string
const idSchema = Joi.alternatives().try(
Joi.string().uuid({ version: 'uuidv4' }),
Joi.string().pattern(/^\d+$/),
);
// one-of: payment must be exactly one method, never both
const payment = Joi.alternatives()
.try(
Joi.object({ card: Joi.object({ pan: Joi.string().creditCard().required() }).required() }),
Joi.object({ wallet: Joi.object({ id: Joi.string().required() }).required() }),
)
.match('one');
// conditional alternatives: shape depends on a sibling
const notification = Joi.object({
channel: Joi.string().valid('sms', 'email').required(),
payload: Joi.alternatives().conditional('channel', {
is: 'sms',
then: Joi.object({ phone: Joi.string().required(), text: Joi.string().max(160).required() }),
otherwise: Joi.object({ to: Joi.string().email().required(), html: Joi.string().required() }),
}),
});
const bookingSchema = Joi.object({
type: Joi.string().valid('one_way', 'round_trip').required(),
departAt: Joi.date().iso().required(),
// round-trip => returnAt required AND must be after departAt
returnAt: Joi.date().iso().when('type', {
is: 'round_trip',
then: Joi.date().greater(Joi.ref('departAt')).required(),
otherwise: Joi.forbidden(),
}),
// tenant-scoped via context
tier: Joi.string()
.valid('standard', 'vip')
.when(Joi.ref('$tenantTier'), {
is: Joi.valid('enterprise').required(),
otherwise: Joi.valid('standard'),
}),
// chained switch
seats: Joi.number().integer().min(1)
.when('tier', {
switch: [
{ is: 'vip', then: Joi.number().max(2) },
{ is: 'standard', then: Joi.number().max(9) },
],
}),
});
await bookingSchema.validateAsync(payload, { context: { tenantTier: req.user.tenantTier } });
import Joi from 'joi';
import { db } from './db';
// Sync custom: business-rule check
const slugSchema = Joi.string()
.lowercase()
.pattern(/^[a-z0-9-]+$/)
.custom((value, helpers) => {
if (RESERVED_SLUGS.has(value)) return helpers.error('slug.reserved');
return value.replace(/-+/g, '-'); // also normalizes
}, 'reserved-slug check')
.messages({ 'slug.reserved': '{#label} "{#value}" is a reserved slug' });
// Async external: uniqueness against DB (only runs if everything else passes)
const emailSchema = Joi.string()
.email()
.lowercase()
.external(async (value, helpers) => {
const exists = await db.users.exists({ email: value });
if (exists) throw new Joi.ValidationError(
'Email already registered',
[{ message: 'email already registered', path: ['email'], type: 'email.taken', context: { value } }],
value
);
return value;
}, 'email uniqueness');
const userSchema = Joi.object({ email: emailSchema, slug: slugSchema });
// MUST use validateAsync because of .external()
const value = await userSchema.validateAsync(input, { abortEarly: false });
const articleInput = Joi.object({
// Joi normalizes whitespace/case/Unicode — safe-to-store form
slug: Joi.string().trim().lowercase().normalize('NFC').max(80).required(),
// Joi caps length but does NOT strip <script>
title: Joi.string().trim().max(200).required(),
// HTML body: validate length + allow string, then sanitize OUT-of-Joi
bodyRaw: Joi.string().max(50_000).required(),
});
import DOMPurify from 'isomorphic-dompurify';
async function createArticle(req: Request) {
const { value } = articleInput.validate(req.body, { abortEarly: false, stripUnknown: true });
const safeBody = DOMPurify.sanitize(value.bodyRaw, { ALLOWED_TAGS: ['p','b','i','a','ul','li'] });
// Use parameterized query, never string-interpolate value.title into SQL
await db.query('INSERT INTO articles (slug, title, body) VALUES ($1,$2,$3)',
[value.slug, value.title, safeBody]);
}
validate.ts
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import Joi, { type ObjectSchema } from 'joi';
type Source = 'body' | 'query' | 'params' | 'headers';
export const validate = (schema: ObjectSchema, source: Source = 'body'): RequestHandler => {
// Compile once at module load; the returned middleware is the only hot path.
const compiled = Joi.isSchema(schema) ? schema : Joi.compile(schema);
return async (req: Request, res: Response, next: NextFunction) => {
try {
const value = await compiled.validateAsync(req[source], {
abortEarly: false,
stripUnknown: true,
convert: true,
allowUnknown: source === 'headers',
errors: { escapeHtml: true, label: 'key' },
context: { user: (req as any).user }, // for $context refs
});
// Replace with normalized value so handlers see numbers/booleans/trimmed strings.
// Note: Express 5 makes req.query a getter; use Object.defineProperty if needed.
(req as any)[source] = value;
next();
} catch (err) {
if (Joi.isError(err)) {
return res.status(400).json({
error: 'ValidationError',
details: err.details.map((d) => ({
path: d.path.join('.'),
message: d.message,
type: d.type,
})),
});
}
next(err);
}
};
};
// usage in router.ts
import { Router } from 'express';
const r = Router();
const createUserBody = Joi.object({ email: Joi.string().email().required(), age: Joi.number().integer().min(18) });
const listUsersQuery = Joi.object({ page: Joi.number().integer().min(1).default(1), q: Joi.string().trim().allow('') });
r.post('/users', validate(createUserBody, 'body'), createUser);
r.get('/users', validate(listUsersQuery, 'query'), listUsers);
// GOOD: compile at module load, reuse per request
const schema = Joi.object({ /* ... */ });
export const handler = (req, res) => {
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true });
if (error) return res.status(400).json({ error });
// ...
};
// BAD: recompiles on every request (10-100x slower)
export const slowHandler = (req, res) => {
const schema = Joi.object({ /* ... */ }); // <-- inside handler
schema.validate(req.body);
};
// .prefs() bakes options into the schema; useful when one schema needs different defaults
const formSchema = baseSchema.prefs({ abortEarly: false, errors: { label: 'key' } });

Q: What’s the difference between .optional(), .allow(null) and .empty('')?

A: optional controls presence (undefined permitted); allow(null) adds null to the valid values list; .empty('') says “treat empty string as if absent” so it triggers default/optional paths instead of failing string rules. They compose: Joi.string().empty('').optional().allow(null) is the common “tolerant” form.

Q: How do you reuse a schema for both POST (create) and PATCH (update)?

A: Define the canonical Joi.object({...}) once, then for PATCH derive base.fork(Object.keys(base.describe().keys), s => s.optional()) (or tailor() with alter targets). For PATCH you typically also pass presence: 'optional' in validate options instead of touching the schema.

Q: When do you pick Joi.alternatives().conditional() over Joi.when()?

A: conditional is “first match wins, stop” — ideal when each branch is a complete alternative type. when is “compose all matching conditions onto the base” — ideal when the base type stays the same and you’re only tightening rules (e.g. min(18) if country === 'US').

Q: How do you validate a discriminated union like { kind: 'a', x } | { kind: 'b', y }?

A: Use alternatives().try(...) of two object schemas, each pinning kind with Joi.string().valid('a').required(), and apply .match('one') so an object that accidentally satisfies both fails fast. This mirrors TypeScript’s discriminated union.

Q: How do you validate confirmPassword === password?

A: confirmPassword: Joi.any().valid(Joi.ref('password')).required().messages({ 'any.only': 'passwords must match' }). No when needed.

Q: How do you let an admin bypass a stricter rule?

A: Pass req.user.role into validate(value, { context: { role } }), then Joi.when(Joi.ref('$role'), { is: 'admin', then: schemaLoose, otherwise: schemaStrict }). Keeps validation pure and unit-testable.

Q: How would you check email uniqueness during validation without coupling Joi to your DB?

A: Inject the repository at module init (closure), expose a function (deps) => Joi.string().email().external(makeUniqueCheck(deps.users)). Keeps the schema testable and the DB swap trivial.

Q: Why would you pick .custom() over a simple if after validation?

A: Centralizing rules in the schema means (a) the OpenAPI/.describe() output reflects them, (b) errors are reported with the same shape/path as built-ins so the Express middleware doesn’t need a special case, and (c) the rule runs as part of abortEarly: false aggregation.

Q: Rich-text comment field — walk through the validation pipeline.

A: Joi: Joi.string().trim().max(5000).required() + stripUnknown: true at the boundary. Then DOMPurify with an explicit ALLOWED_TAGS allowlist. Then store via parameterized query. Render with framework auto-escaping. Validation, sanitization, persistence, and rendering are four separate concerns.

Q: Is Joi.string().email() enough to prevent header injection in a signup form?

A: No. .email() checks RFC structure but doesn’t guard CRLF or address-spoofing in downstream SMTP code. Use a dedicated mail library that escapes headers, and consider an explicit .pattern(/^[^\r\n]+$/).

Q: Why stripUnknown: true and not allowUnknown: true?

A: allowUnknown lets unknown fields pass through into your handler (security risk if you spread req.body into your DB). stripUnknown removes them, so even if a client sends { email, isAdmin: true } your handler only sees { email }. Use allowUnknown only for headers.

Q: How would you support both JSON body and multipart/form-data with the same schema?

A: Multipart fields all arrive as strings; rely on convert: true to coerce numbers/booleans, and put the file metadata under a separate sub-schema validated after Multer. Don’t try to validate the file Buffer with Joi.

Q: Your validation middleware is showing 8 ms p99 — what would you check?

A: (a) Confirm the schema is built at module scope, not in the handler. (b) Profile .when()/alternatives chains; replace deeply nested when with a discriminated alternatives if possible. (c) Check convert: true on fields with regex like Joi.string().pattern(/expensive-regex/) — catastrophic backtracking is the usual culprit. (d) Drop errors: { stack: true } if accidentally enabled.

Q: Joi.attempt vs schema.validate() — when each?

A: attempt for startup config validation where you want a fail-fast crash with stack trace (Joi.attempt(process.env, envSchema)); validate for request handling because you want a value+error tuple to format into a 400 without try/catch noise.