Validation (Joi) — Basics
What is Joi
Section titled “What is Joi”Runtime schema description + data validator for JavaScript. Originally extracted from hapi ecosystem, now maintained as hapijs/joi. Notes target Joi 17.x.
Why a separate validation layer
Section titled “Why a separate validation layer”- Boundary check — never trust HTTP input.
- Normalize — trim whitespace, lowercase email, coerce
"5"→5. - Document — schemas double as a typed contract;
.describe()reflects into OpenAPI/admin UIs. - Centralize errors — single 400-formatter instead of
ifs scattered through handlers.
Schema basics
Section titled “Schema basics”Joi schemas are immutable, fluent builders: every method call returns a new schema, so chaining .required().default(...) is safe across modules.
Root types: Joi.object(), Joi.string(), Joi.number(), Joi.array(), Joi.boolean(), Joi.date(), Joi.alternatives(), Joi.any().
Composition:
| Method | Effect |
|---|---|
.keys({...}) | Extend allowed keys |
.append({...}) | Sugar for .keys() |
.concat(other) | Merge two same-typed schemas — rules of both apply |
.extract(path) | Pull a sub-schema out (e.g. for partial-update endpoints) |
.label('x') | Rewrite error label from default key path |
.strip() | Remove the key from validated output (write-only fields like password) |
.allow(null) | Permit literal null without changing presence |
Presence semantics
Section titled “Presence semantics”| Modifier | Meaning |
|---|---|
.required() | Must be present (not undefined) |
.optional() | May be undefined (default) |
.forbidden() | Must be absent — fails if present |
.allow(null) | Adds null to valid values list |
.empty('') | Treat empty string as if absent (triggers default/optional) |
They compose: Joi.string().empty('').optional().allow(null) is the common “tolerant” form.
Validation entrypoints
Section titled “Validation entrypoints”| Call | Behavior |
|---|---|
schema.validate(value, opts) | Sync; returns { value, error } |
schema.validateAsync(value, opts) | Async (required if .external()); resolves value or throws |
Joi.attempt(value, schema) | Throws on failure (good for startup config) |
Joi.assert(value, schema) | Same but discards value |
Common opts
Section titled “Common opts”| Option | Effect |
|---|---|
abortEarly: false | Collect all errors (forms want this) |
stripUnknown: true | Remove unknown keys from output |
allowUnknown: true | Let unknown keys pass through (only for headers) |
convert: true (default) | Coerce types ("5" → 5, "true" → true) |
context: { … } | Values available via Joi.ref('$key') |
Quick gotchas
Section titled “Quick gotchas”.allow(null)≠.optional(). Often need both..default(obj)shares reference. Use.default(() => ({...})).Joi.object()with no.keys()allows any child key.Joi.object({})allows none..custom()is sync. Returning a Promise is treated as truthy value. Use.external()for async..external()runs only after schema passes AND only viavalidateAsync().- Joi is not a security sanitizer. No XSS strip, no SQL escape. Layer DOMPurify + parameterized queries.
- In Express 5,
req.queryis a getter. Mutating it throws. Store onreq.validatedorObject.defineProperty.
Top references
Section titled “Top references”- Official API: joi.dev/api
- Source-of-truth: github.com/hapijs/joi/blob/master/API.md
- Changelog: joi.dev/resources/changelog
- Express integration: evanshortiss/express-joi-validation, arb/celebrate