Handling Async Validation in Joi Schemas
An async check — uniqueness against a database, a remote lookup against a third-party API — silently passes when the code calls schema.validate(). No error is thrown. The payload reaches the handler as if valid. The root is straightforward: validate() is synchronous and discards any Promise a validator returns without waiting for it. This guide is part of Joi and Yup for Legacy Systems and gives the exact Joi 17 fix: replace every async check with external(), switch every call site to validateAsync(), and map the resulting ValidationError to a structured response.
Symptom: Async Check Always Passes Silently
The failure looks like this in practice. A registration endpoint accepts a duplicate email with a 201 response even though the schema contains a uniqueness check:
// validation/userSchema.ts — Joi 17.13.3
import Joi from 'joi';
import { userRepo } from '../db/userRepo';
const schema = Joi.object({
email: Joi.string()
.email({ tlds: false })
.required()
.external(async (value) => {
const taken = await userRepo.emailExists(value);
if (taken) throw new Error('email already registered');
}),
name: Joi.string().max(120).required(),
});
// route handler — the bug
import { schema } from '../validation/userSchema';
export async function register(req, res) {
// BUG: validate() is synchronous. external() validators are silently skipped.
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(422).json({ details: error.details });
}
await userRepo.create(value); // duplicate email reaches the database
return res.status(201).json({ id: value.id });
}
No exception is raised. No error object is populated. validate() returns { error: undefined, value: { email: 'dup@example.com', name: 'Alice' } } as if the external check never existed.
Root Cause: validate() Is Synchronous and Ignores Promises
Joi 17 exposes two distinct validation entry points with fundamentally different execution models.
schema.validate(value, options) is a synchronous, blocking call. It executes the entire schema graph on the current call stack and returns { error, value } immediately. When Joi encounters an external() field during a validate() call it does not invoke the function. The external validator is simply skipped. This is by design — Joi cannot await inside a synchronous function without blocking the event loop, so the entire external() API was built to require validateAsync().
schema.validateAsync(value, options) returns a Promise<value>. Joi runs synchronous rules first, then invokes each external() function in schema-declaration order, awaiting each one before proceeding to the next. If any external validator throws, Joi wraps the error in a Joi.ValidationError and rejects the Promise.
The silent-pass bug occurs because engineers often reach for validate() out of habit — it is the more visible API in tutorials — and add an external() rule later without realising it is incompatible. There is no compile-time warning, no runtime warning, and no lint rule in default Joi configs that flags the mismatch.
Step-by-Step Fix
Step 1: Define the external() validator correctly
external() is Joi 17’s dedicated hook for async work. It receives the already-synchronously-validated value and a helpers object, and must either return the value unchanged (or a transformed value) or throw to signal failure.
// validation/userSchema.ts — Joi 17.13.3
import Joi from 'joi';
import { userRepo } from '../db/userRepo';
export const registrationSchema = Joi.object({
email: Joi.string()
.email({ tlds: false })
.lowercase() // synchronous normalisation — runs before external()
.required()
.external(async (value, helpers) => {
// external() only fires under validateAsync(). Return value to pass,
// throw to fail. Use helpers.error() for a typed Joi error code;
// a plain throw produces a generic 'any.external' error type.
let taken: boolean;
try {
taken = await userRepo.emailExists(value);
} catch (dbErr) {
// Convert infrastructure errors into a typed Joi error so the
// caller always catches Joi.ValidationError, not a raw DB error.
throw helpers.error('any.custom', { message: 'email lookup failed' });
}
if (taken) {
throw helpers.error('any.custom', { message: 'email already registered' });
}
return value; // unchanged — pass it through
}),
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required()
.external(async (value, helpers) => {
// Second external() — evaluated after email's external() resolves,
// in schema-declaration order.
const taken = await userRepo.usernameExists(value);
if (taken) throw helpers.error('any.custom', { message: 'username already taken' });
return value;
}),
password: Joi.string().min(12).required(),
});
Why this works: external() is registered as a post-synchronous-validation hook. Joi’s synchronous rules (.email(), .lowercase(), .required()) run and short-circuit first. Only a synchronously valid value reaches the async hook, which prevents unnecessary database queries when the format itself is already wrong.
Step 2: Switch every call site to validateAsync()
Any route handler, middleware, or test that calls schema.validate() on a schema that contains external() must be updated. The switch is mechanical but must be exhaustive — a single missed call site silently re-introduces the bug.
// middleware/validateRegistration.ts
import { Request, Response, NextFunction } from 'express';
import Joi from 'joi';
import { registrationSchema } from '../validation/userSchema';
export async function validateRegistration(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
// validateAsync returns a Promise<value>. The value is the Joi-processed
// payload (lowercased, stripped of unknown keys, etc.).
// abortEarly:false collects all failures before rejecting.
const value = await registrationSchema.validateAsync(req.body, {
abortEarly: false,
// stripUnknown removes keys not in the schema from the returned value;
// combine with allowUnknown:false (default) to hard-fail on them instead.
stripUnknown: true,
});
// Replace req.body with the processed value so the handler trusts it.
req.body = value;
return next();
} catch (err) {
if (err instanceof Joi.ValidationError) {
// Map Joi's error structure to a consistent 422 contract.
// See /schema-design-validation-patterns/designing-robust-error-response-contracts/
res.status(422).json({
error: 'VALIDATION_FAILED',
details: err.details.map((d) => ({
field: d.path.join('.'),
rule: d.type,
message: d.message,
})),
});
return;
}
// Non-Joi errors (unexpected throws) propagate to the global error handler.
return next(err);
}
}
Why this works: validateAsync() returns a Promise that Joi resolves only after every synchronous rule and every external() hook has settled. Wrapping in try/catch is the correct async error-handling pattern: Joi rejects the Promise with a Joi.ValidationError when any rule fails, so err instanceof Joi.ValidationError distinguishes schema failures from infrastructure errors cleanly.
Step 3: Map ValidationError details to a client response
Joi.ValidationError exposes a details array. Each entry carries .path (the field path as an array), .type (the error code, e.g. 'any.required', 'string.email', 'any.custom'), and .message (the human-readable string). For external() errors using helpers.error('any.custom', { message: '...' }), the .message is whatever you passed in message.
// The structured 422 body a well-formed client can parse:
// {
// "error": "VALIDATION_FAILED",
// "details": [
// {
// "field": "email",
// "rule": "any.custom",
// "message": "email already registered"
// }
// ]
// }
If you need a distinct error code per async failure type — so clients can distinguish “email taken” from “username taken” — use a custom errorCode instead of relying solely on the generic any.custom type:
// Extended mapping with a client-facing code:
details: err.details.map((d) => ({
field: d.path.join('.'),
rule: d.type,
message: d.message,
// Pull a custom code if the external() call attached context:
code: (d.context as { errorCode?: string } | undefined)?.errorCode ?? d.type,
})),
To attach that context from inside external():
.external(async (value, helpers) => {
if (await userRepo.emailExists(value)) {
throw helpers.error('any.custom', {
message: 'email already registered',
errorCode: 'EMAIL_TAKEN', // propagates to d.context.errorCode
});
}
return value;
})
Why this works: helpers.error(type, context) constructs a ValidationError detail with the given type code and merges the context object into d.context. This gives you a typed, machine-readable error surface without building a separate error class hierarchy.
Step 4: Handle ordering and abortEarly for performance
By default validateAsync() inherits the same abortEarly: true default as validate(). With abortEarly: true, Joi stops at the first synchronous failure before ever calling any external(). With abortEarly: false, all synchronous errors are collected first, and then — if no synchronous errors exist — external validators run in declaration order, each still aborting on its own first failure.
// Behaviour matrix:
// abortEarly: true (default) — stops at first failure, minimises DB queries
// abortEarly: false — all sync errors collected; if all pass,
// external() validators run in order, stopping
// at the first async failure per field.
// For a registration form where showing all errors at once is the UX requirement:
const value = await registrationSchema.validateAsync(req.body, {
abortEarly: false, // collect all sync errors before touching the DB
});
// For a high-throughput internal service where you only need the first error
// and want to minimise latency:
const value = await registrationSchema.validateAsync(req.body, {
abortEarly: true, // stop at the first failure, skip remaining lookups
});
Before and After
Before — validate() silently ignores external():
// BEFORE: external() is registered but never executed
const { error, value } = registrationSchema.validate(req.body, { abortEarly: false });
// error is always undefined for async failures
// duplicate email passes through undetected
After — validateAsync() awaits the check:
// AFTER: external() runs, DB is queried, duplicates are rejected
try {
const value = await registrationSchema.validateAsync(req.body, { abortEarly: false });
req.body = value;
next();
} catch (err) {
if (err instanceof Joi.ValidationError) {
return res.status(422).json({ error: 'VALIDATION_FAILED', details: err.details });
}
next(err);
}
The only change in the happy path is await and the try/catch. In the error path the 422 body is identical to what a synchronous failure produces — no special handling needed on the client side.
Verification
Write a Jest test that asserts both directions: a duplicate must reject, a unique value must resolve. Also include the negative proof — validate() on the same payload must pass, confirming the bug was real and the fix is necessary.
// __tests__/registrationSchema.test.ts — Jest 29, Joi 17.13.3
import Joi from 'joi';
import { registrationSchema } from '../validation/userSchema';
import { userRepo } from '../db/userRepo';
jest.mock('../db/userRepo');
const mockedRepo = userRepo as jest.Mocked<typeof userRepo>;
describe('registrationSchema async validation', () => {
const validPayload = {
email: 'alice@example.com',
username: 'alice99',
password: 'correcthorsebatterystaple',
};
beforeEach(() => jest.clearAllMocks());
it('resolves when email and username are unique', async () => {
mockedRepo.emailExists.mockResolvedValue(false);
mockedRepo.usernameExists.mockResolvedValue(false);
await expect(
registrationSchema.validateAsync(validPayload, { abortEarly: false }),
).resolves.toMatchObject({ email: 'alice@example.com' });
expect(mockedRepo.emailExists).toHaveBeenCalledWith('alice@example.com');
});
it('rejects with VALIDATION_FAILED when email is taken', async () => {
mockedRepo.emailExists.mockResolvedValue(true); // taken
mockedRepo.usernameExists.mockResolvedValue(false);
await expect(
registrationSchema.validateAsync(validPayload, { abortEarly: false }),
).rejects.toSatisfy(
(err: unknown) =>
err instanceof Joi.ValidationError &&
err.details[0].message === 'email already registered',
);
});
it('PROOF: validate() silently passes a duplicate (demonstrates the bug)', () => {
// This test documents that the synchronous entry point is broken for async rules.
// It should PASS — meaning validate() wrongly returns no error.
mockedRepo.emailExists.mockResolvedValue(true); // taken, but ignored
const { error } = registrationSchema.validate(validPayload, { abortEarly: false });
// validate() returns no error even though the email is taken.
// This is why the fix is validateAsync(), not validate().
expect(error).toBeUndefined();
expect(mockedRepo.emailExists).not.toHaveBeenCalled(); // never even queried
});
});
Run with:
npx jest registrationSchema --verbose
Expected output:
PASS __tests__/registrationSchema.test.ts
registrationSchema async validation
✓ resolves when email and username are unique (12 ms)
✓ rejects with VALIDATION_FAILED when email is taken (5 ms)
✓ PROOF: validate() silently passes a duplicate (demonstrates the bug) (2 ms)
Tests: 3 passed, 3 total
The third test is the regression guard: if it starts failing, something has changed in how Joi handles external() under validate(), and the assumption behind the fix needs re-examination.
Edge Cases and Caveats
External() and abortEarly interact in a non-obvious order. Joi completes all synchronous rule evaluations before invoking any external() function. If abortEarly: false is set and two fields both fail synchronous checks, neither external() fires — this is the correct behaviour (no point querying the DB when the email address isn’t even a valid email format). If all synchronous checks pass, external validators run in declaration order. With abortEarly: true (the default), the first external failure stops the chain; with abortEarly: false, each external validator still runs to completion independently, and all errors are collected.
Wrapping infrastructure errors is mandatory. If userRepo.emailExists() throws a database connection error and you do not catch it inside external(), Joi re-throws it as a plain Error rather than a Joi.ValidationError. Your middleware err instanceof Joi.ValidationError check will be false, the error will reach next(err), and the client will receive a 500 instead of a 422. Always wrap infrastructure calls in a try/catch inside external() and convert them to helpers.error() calls or re-throw as structured application errors explicitly.
Performance: avoid N+1 DB queries per request. If the schema has five fields each with an external() uniqueness check, a single request fires up to five sequential database queries. Batch where possible: a single SELECT email, username FROM users WHERE email = $1 OR username = $2 can resolve both lookups in one round-trip. Perform the batch query in the first external() and cache the result in the validation context using helpers.state.ancestors or by closing over a shared variable initialised before the validateAsync() call.
Frequently Asked Questions
Why does my Joi external() validator never reject anything?
external() only runs under validateAsync(). A plain validate() call silently skips all external validators and returns valid regardless of the async check result. Audit every call site that uses a schema with external() and ensure it awaits validateAsync().
Can I use Joi.custom() for async logic instead of external()?
No. custom() is synchronous; returning a Promise from it does not schedule execution — Joi resolves the return value immediately and treats a Promise object as a valid value. Always use external() for I/O-bound checks.
How do I abort validation after the first async error to save round-trips?
Pass abortEarly: true (the default) to validateAsync(). Joi evaluates synchronous rules first, then external validators. With abortEarly: true it stops at the first failure, which avoids firing every database lookup when an earlier field already failed.
What happens if my external() function throws a non-Joi error?
If you throw a plain Error (not a Joi.ValidationError), Joi re-throws it as-is rather than wrapping it in a ValidationError. Catch internal errors inside the external() function and convert them to helpers.error() calls so the caller always receives a ValidationError.
Is validateAsync safe to use in Express middleware?
Yes, as long as you await it inside an async middleware function and catch errors with a try/catch or an async error-handler wrapper like express-async-errors. Unhandled Promise rejections from validateAsync will crash the process in Node 15 and later.
Does the order of fields in the schema affect which external() fires first?
Yes. Joi evaluates external validators in schema-declaration order. Place uniqueness checks on the most-likely-to-fail field first (usually email) to short-circuit expensive secondary lookups when abortEarly is true.