Validation (Joi) — Practical
Schema composition
Section titled “Schema composition”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 stringconst idSchema = Joi.alternatives().try( Joi.string().uuid({ version: 'uuidv4' }), Joi.string().pattern(/^\d+$/),);
// one-of: payment must be exactly one method, never bothconst 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 siblingconst 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() }), }),});Conditional rules with .when()
Section titled “Conditional rules with .when()”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 } });Custom + external validators
Section titled “Custom + external validators”import Joi from 'joi';import { db } from './db';
// Sync custom: business-rule checkconst 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 });Sanitization layered with DOMPurify
Section titled “Sanitization layered with DOMPurify”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]);}Express middleware factory
Section titled “Express middleware factory”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.tsimport { 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);Perf — compile once
Section titled “Perf — compile once”// GOOD: compile at module load, reuse per requestconst 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 defaultsconst formSchema = baseSchema.prefs({ abortEarly: false, errors: { label: 'key' } });Interview Q&A
Section titled “Interview Q&A”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.