Async Business Rules
Problem
A value can be structurally valid but still fail business rules such as uniqueness checks or reserved names. Those rules usually need async I/O.
Solution
Use validate() with an async callback. Spell awaits the callback when you call parseAsync() or safeParseAsync().
ts
import { s } from '@vielzeug/spell';
const takenEmails = new Set(['ada@example.com']);
const bannedDomains = new Set(['example.org']);
async function isEmailAvailable(value: string) {
return !takenEmails.has(value);
}
const Account = s.object({
email: s
.string()
.email()
.validate(async (value, ctx) => {
const domain = value.split('@')[1] ?? '';
if (bannedDomains.has(domain)) {
ctx.addIssue({ code: 'custom', message: 'Email domain is blocked', path: [] });
}
if (!(await isEmailAvailable(value))) {
ctx.addIssue({ code: 'custom', message: 'Email is already in use', path: [] });
}
}),
password: s.string().min(12),
});
const result = await Account.safeParseAsync({
email: 'grace@example.com',
password: 'horse-battery-staple',
});
console.log(result.success);Simple string shorthand
When only one error message is needed, return the message directly:
ts
import { s } from '@vielzeug/spell';
const takenSlugs = new Set(['about', 'contact']);
const Slug = s
.string()
.slug()
.validate(async (v) => (!takenSlugs.has(v) ? null : `'${v}' is already taken`));
await Slug.parseAsync('about'); // throws: 'about' is already taken
await Slug.parseAsync('changelog'); // 'changelog'Pitfalls
- Async
validate()callbacks are silently skipped in synchronousparse(). Always useparseAsync()when any rule performs I/O. - Keep I/O inside
validate(), not insidetransform(). Validation failures must go through the issue model. - Call
ctx.addIssue()more than once to emit multiple issues from a single async rule.