@ololoepepe/validator
v0.4.1
Published
Data validator
Readme
Validator
Data validator
Install
npm install @ololoepepe/validatorThe package is ESM-only and ships three builds, one per consumer:
| Consumer | Artefact | How it is reached |
| --- | --- | --- |
| Node | dist/node — untranspiled ES modules plus declarations | import {createValidator} from '@ololoepepe/validator' |
| A bundler targeting browsers | dist/browser — one transpiled file per module, so an unused export can be dropped | the browser export condition, resolved automatically |
| A plain <script src="…"> | dist/umd — a single UMD bundle exposing the global validator | @ololoepepe/validator/umd |
The browser build targets browsers released since 2020 and carries its polyfills as
pure imports from core-js-pure, so nothing is patched onto the globals.
The package is written in TypeScript and ships its own declarations; nothing extra is needed to get types. See TypeScript for what is inferred from a template.
Example
import Crypto from 'node:crypto';
import {createValidator, ValidatorError} from '@ololoepepe/validator';
import DB from '../lib/database.js';
const UserValidator = createValidator({
type: 'object',
fields: {
nickname: {
type: 'string', // Nickname must be a string
minLength: 4, // with a minimum length of 4
maxLength: 20, // and maximum length of 20
regexp: /^[a-zA-Z0-9_]+$/ // consisting of latin letters, digits and underscores
},
password: {
type: 'string', // password must be a string
minLength: 8, // with a minimum length of 8
maxLength: 32, // and maximum length of 32
validateAfter: (pwd: string) => {
// A password must contain at least one digit
if (!/[0-9]/.test(pwd)) {
throw new ValidatorError('PASSWORD_HAS_NO_DIGITS');
}
// A password must contain at least one lowercase letter
if (!/[a-z]/.test(pwd)) {
throw new ValidatorError('PASSWORD_HAS_NO_LOWERCASE_LETTERS');
}
// A password must contain at least one uppercase letter
if (!/[A-Z]/.test(pwd)) {
throw new ValidatorError('PASSWORD_HAS_NO_UPPERCASE_LETTERS');
}
},
transformAfter: async (pwd: string) => {
const salt = await DB.from('salt').select('value').where('type', '=', "'password'");
// A password must be hashed after validation
return Crypto.createHash('sha1').update(pwd).update(salt).digest('hex');
}
},
kittens: {
type: 'number',
minValue: 0,
// A value may be passed as a string, and it will be converted to number before any validation
transformBefore: (count: string) => Number(count)
}
}
});
const result = await UserValidator.validate({
nickname: 'Vasia',
password: 'pwd123PWD',
kittens: 1
});
console.log(result);
/* {
ok: true,
value: {
nickname: 'Vasia',
password: 'f3e4c83aa7c6a1da18f1facf63dcdf21c3f8a881',
kittens: 1
}
} */
console.log(await UserValidator.validate({nickname: 'Petia)))', password: 'pwd321PWD', kittens: 0}));
/* {
ok: false,
error: {
code: 'INVALID_STRING',
data: 'Petia)))',
path: ['nickname'],
info: {}
}
} */
console.log(await UserValidator.validate({nickname: 'Slava', password: '123', kittens: '0'}));
/* {
ok: false,
error: {
code: 'INVALID_STRING_LENGTH',
data: '123',
path: ['password'],
info: {expectedMinLength: 8, length: 3}
}
} */Only the failing branch carries an error, and only the succeeding branch carries a value,
so the two are never both present:
const result = await UserValidator.validate(payload);
if (!result.ok) {
return reply.code(400).send({code: result.error.code, path: result.error.path});
}
return createUser(result.value);When throwing suits the call site better, use parse:
const user = await UserValidator.parse(payload); // rejects with a ValidatorErrorAPI
createValidator(template, {customTypes = [], maxDelayMsecs = 1} = {})
Creates a validator. Accepts validation template, an object in the following form:
{
type: <type>,
missingStrategy: [MissingStrategy], // optional
extraStrategy: [ExtraStrategy], // optional
defaultValue: [default_value], // optional, only required if missingStrategy is MissingStrategy.Default
values: Array, // optional, an array of allowed values, only valid for types 'string', 'boolean', 'number'
validateBefore: (data, options) => {}, // optional, a custom validation function called before other validators
validateAfter: (data, options) => {}, // optional, a custom validation function called after other validators
transformBefore: (data, options) => {}, // optional, a custom function to transform the value before validation
transformAfter: (data, options) => {}, // optional, a custom function to transform the value after validation
// ...fields specific for the <type>
}validateBefore validates data after checking the type, but before any default validators.
validateAfter validates data after default validators, just before transformAfter.
Every hook may be synchronous or async; whatever it returns is awaited. transformBefore,
transformAfter and a custom type's validate replace the value with what they return;
validateBefore and validateAfter only inspect it, and their return value is ignored.
values must contain no duplicates, and its items must be of the template's own type — both
are checked when the validator is created, not when data arrives.
The option set is closed
A template may only carry the options its type actually reads. An option that is misspelled,
or that belongs to another type, is rejected when the validator is created:
createValidator({type: 'string', maxLenght: 254});
// Error: Validator: Unknown .maxLenght
createValidator({type: 'number', maxLength: 3});
// Error: Validator: Unknown .maxLength (maxLength is a string and array option)Nothing would ever read such an option, so a template carrying one validates less than it was written to — silently, which is the worst way for a validator to fail. TypeScript reports the same mistake at the call site, on the offending option and at any depth:
createValidator({
type: 'object',
fields: {
nickname: {type: 'string', maxLenght: 20}
// ~~~~~~~~~ Type 'number' is not assignable to type 'never'
}
});hint is part of that set only inside variants, which is the only place it is read.
Example:
{
type: 'string',
regexp: /\d+/,
transformAfter: value => Number(value)
}Reporting failures from a hook
Throw a ValidatorError with a code and, optionally, an info:
validateAfter: async (value) => {
if (await isTaken(value)) {
throw new ValidatorError('NICKNAME_TAKEN', {info: {nickname: value}});
}
}A hook knows its code and info but not where in the document it sits, so path and
data are filled in by the validator.
Anything else a hook throws is treated as a bug rather than a validation failure: it is not
converted into a ValidatorError and it rejects the promise instead of appearing in the result.
A TypeError from a typo in a hook therefore surfaces as itself.
validate(data, {context = {}, maxDelayMsecs = validatorMaxDelayMsecs} = {})
Performs validation. Resolves with {ok: true, value} on success, or {ok: false, error} where
error is a ValidatorError.
data-- data to be validated
Possible options are:
context-- a context passed to custom validation functions (an empty object by default)maxDelayMsecs-- if an uninterrupted stretch of validation takes longer than this many milliseconds, the next portion is deferred to a later event loop turn.Infinitydisables interruptions entirely. See Interruptions.
parse(data, {context = {}, maxDelayMsecs = validatorMaxDelayMsecs} = {})
Same as validate, but resolves with the validated value directly and rejects with a
ValidatorError instead of returning it.
ValidatorErrorCode
Constant containing the error codes:
{
BooleanNotInValues: 'BOOLEAN_NOT_IN_VALUES',
CustomError: 'CUSTOM_ERROR',
DuplicateItems: 'DUPLICATE_ITEMS',
ExtraFields: 'EXTRA_FIELDS',
InvalidArrayLength: 'INVALID_ARRAY_LENGTH',
InvalidNumber: 'INVALID_NUMBER',
InvalidString: 'INVALID_STRING',
InvalidStringLength: 'INVALID_STRING_LENGTH',
MissingField: 'MISSING_FIELD',
NumberNotInteger: 'NUMBER_NOT_INTEGER',
NumberNotInValues: 'NUMBER_NOT_IN_VALUES',
NoMatchingVariant: 'NO_MATCHING_VARIANT',
StringNotInValues: 'STRING_NOT_IN_VALUES',
TypeMismatch: 'TYPE_MISMATCH',
UnallowedNull: 'UNALLOWED_NULL'
}Types
There are several builtin types.
object
A JavaScript object.
Example:
{
type: 'object',
fields: {
// nested fields
}
}Data keys that are not declared in fields are treated as extra fields and handled
according to extraStrategy (error by default, or exclude / include). Detection is
based on own properties (Object.hasOwn), so a data key named after an inherited object
member (e.g. toString, valueOf, constructor) is also reported as an extra field rather
than being silently ignored.
array
A JavaScript array.
Example:
{
type: 'array',
element: {
// element definition
}
}string
A string.
Example:
{
type: 'string',
minLength: [M], // optional, minimum string length
maxLength: [N], // optional, maximum string length
length: [K], // optional, exact string length
regexp: [R] // optional, a regular expression to match the string against
}number
A number.
Example:
{
type: 'number',
minValue: [M], // optional, minimum value
maxValue: [N], // optional, maximum value
isInteger: [true|false] // optional, requires the value to be a safe integer (Number.isSafeInteger)
}NaN is not accepted as a valid number and fails with a TypeMismatch error.
When isInteger is true, the value must be a safe integer: Infinity, -Infinity,
NaN and integers outside the Number.isSafeInteger range (magnitude greater than 2^53 − 1,
which cannot be represented exactly) are rejected with a NumberNotInteger error.
boolean
A boolean.
Example:
{
type: 'boolean'
}variant
Used to validate against multiple templates. The result of first successful validation is used.
Example:
{
type: 'variant',
variants: [{
type: 'string',
regexp: /\d+/,
transformAfter: value => Number(value)
}, {
type: 'number'
}]
}If hint function is provided to template variants, raw data is checked against that function.
Templates with hint returning false-ish values are excluded from validation.
Templates are used in the same order they are specified.
This may be used to optimize large template validation.
Example:
[{
type: 'object',
hint: rawValue => rawValue.kind === 'KIND_A',
fields: {
kind: {
type: 'string',
values: ['KIND_A']
}
// Dozens of fields for kind A
}
}, {
type: 'object',
hint: rawValue => rawValue.kind === 'KIND_B',
fields: {
kind: {
type: 'string',
values: ['KIND_B']
}
// Dozens of fields for kind B, different from the ones for kind A
}
}]If no variant matches the data, a NoMatchingVariant error occurs.
Advanced
isNullable
Any type can be allowed to be null.
Example:
{
type: 'string',
isNullable: true
}Object prototype (class) as "type"
Example:
class CustomClass {
constructor() {
//
}
}
{
type: CustomClass
}The value is checked with instanceof and passed through as it is, so an instance keeps its
identity and its methods. Register a custom type to validate its contents.
Custom types
A custom type is registered on the validator and named by a template's type:
const validator = createValidator({type: 'isoDate'}, {
customTypes: [{
basicType: 'string', // what the value must be
match: (value) => !Number.isNaN(Date.parse(value)), // whether this type applies
type: 'isoDate',
validate: (value) => new Date(value) // what the value becomes
}]
});basicType is what the value is held against before the custom type is given it — a basic type
or a class. A custom type registered over a class is what makes {type: SomeClass} resolve to
it, so a class-typed template and a named one reach the same code.
A custom type's validate replaces the body of the validation: it is handed the value and
decides everything about it. None of the built-in options of its basicType is read, and a
template naming a custom type may therefore not carry one. Where the built-in validation is
wanted first, write it as the type it refines and chain into the custom type with next:
{
type: 'array',
element: {type: 'string'},
next: {type: 'nonEmptyList'} // gets the already validated array
}Interruptions
Validating a large document is a long stretch of work with no I/O in it. Left alone it would
hold the thread for its whole duration — starving incoming requests on the server, and blocking
rendering and input in the browser. So the validator interrupts itself: whenever an
uninterrupted stretch exceeds maxDelayMsecs (1 by default), it hands control back and resumes
on a later turn of the event loop.
async/await does not provide this by itself. Awaiting yields to the microtask queue, and
that queue is drained completely before the event loop advances to its next phase — a traversal
made entirely of await blocks I/O exactly as thoroughly as a synchronous loop would. An
interruption therefore has to be a macrotask, and the mechanism is picked by feature detection:
setImmediatewhere it exists (Node);- otherwise a
MessageChannelround trip (the browser) — unlikesetTimeout(fn, 0), it is not subject to the 4ms clamp the HTML specification imposes on nested timeouts, and every interruption here is scheduled from inside the previous one; setTimeoutas a last resort.
Set maxDelayMsecs: Infinity to turn interruptions off, for instance when validating small
payloads in a hot path. Nothing then reads the clock either.
TypeScript
A validator carries the type its template produces, so a successful result needs no annotation and no cast:
const UserValidator = createValidator({
type: 'object',
fields: {
nickname: {type: 'string', minLength: 4},
role: {type: 'string', values: ['admin', 'user']},
kittens: {type: 'number', missingStrategy: 'default', defaultValue: 0},
bio: {type: 'string', isNullable: true, missingStrategy: 'ignore'},
tags: {type: 'array', element: {type: 'string'}}
}
});
const result = await UserValidator.validate(payload);
if (!result.ok) {
return reply.code(400).send({code: result.error.code, path: result.error.path});
}
result.value;
// ^? {
// nickname: string;
// role: 'admin' | 'user';
// kittens: number;
// bio?: string | null;
// tags: string[];
// }No as const is required — the template literal is captured through a const type
parameter. Templates may be written with the exported enums (BasicType.String) or with
plain strings ('string'); both infer identically.
What the inference follows:
| in the template | in the result |
|---|---|
| values: [...] | a literal union rather than string / number / boolean |
| isNullable: true | T \| null |
| missingStrategy: 'ignore' | the field becomes optional — the validator really does omit it |
| missingStrategy: 'default' | union with the type of defaultValue |
| type: 'array' | Infer<element>[] |
| type: 'variant' | a union, discriminated when the variants share a values-constrained field |
| type: SomeClass | InstanceType<SomeClass> |
| extraStrategy: 'include' | adds [key: string]: unknown |
| transformAfter | whatever it returns, with a promise unwrapped |
| next | the type of the chained template |
| a custom type by name | whatever that type's validate returns |
Infer is exported for the cases where the type is needed on its own:
import type {Infer} from '@ololoepepe/validator';
type User = Infer<typeof UserValidator.template>;Recursive templates
A template that refers to itself would make the type checker recurse forever, so the
reference is deferred and annotated once with lazy:
interface Comment {
text: string;
replies?: Comment[];
}
const commentTemplate: TemplateShape = {
type: 'object',
fields: {
text: {type: 'string', minLength: 1},
replies: {
type: 'array',
missingStrategy: 'ignore',
element: lazy<Comment>(() => commentTemplate)
}
}
};The thunk is resolved when validation first reaches that node, and the resolved template is validated and cached like any other, so the cycle terminates.
How a wrong option is reported
TemplateShape is a union discriminated by type — StringTemplateShape,
NumberTemplateShape and one member per type, each declaring the options that type reads.
createValidator takes the template as CheckedTemplate<T> & T, which resolves that member
from the template's own type and gives every option the type does not have the type never:
createValidator({type: 'number', maxLength: 3});
// ~~~~~~~~~ Type 'number' is not assignable to type 'never'The constraint alone cannot do this. TypeScript checks an inferred type argument against its
constraint by plain assignability, and assignability ignores extra properties, so a misspelled
option satisfies T extends TemplateShape unopposed however precisely the shape is written —
which is why the check is a separate type in the parameter rather than a stricter constraint.
An option the type cannot do without is demanded there as well, a mapped type otherwise walking the options that are present rather than the ones that should be:
createValidator({type: 'array'});
// Property 'element' is missing in type '{ type: "array"; }' but required in type
// 'RequiredOptions<ArrayTemplateShape>'A template written behind an annotation (const t: TemplateShape = {...}) is checked by the
excess property check instead, which reports the same mistake with a suggestion:
const t: TemplateShape = {type: 'string', maxLenght: 4};
// Object literal may only specify known properties, but 'maxLenght' does not exist in type
// 'CustomTemplateShape | StringTemplateShape'. Did you mean to write 'maxLength'?Neither reaches a template assembled at runtime, and neither exists in JavaScript, so
validateTemplate makes the same check when the validator is created — see
The option set is closed.
What is not inferred
- The input of a hook.
transformAfter: (value: string) => Number(value)needs the parameter annotated; only the result is inferred. TypeScript cannot use a partially inferred template to contextually type another property of the same literal. - A custom type reached through a class.
{type: SomeClass}infersInstanceType<SomeClass>. When a custom type registered under that class returns something else fromvalidate, refer to it by its string name instead. - A template stored with a widening annotation.
const t: TemplateShape = {...}erases the literal types; leave the annotation off, or uselazywhere one is needed to break a cycle.
