Skip to content

Validation ​

schema.check(value, options?) validates a value and returns a ValidationResult:

js
const { Schema } = require('@alexify/metaschema');

const user = new Schema('User', { name: 'string', age: '?number' });

const result = user.check({ age: 'old' });
result.valid; // false
result.errors;
// [
//   'Field "User.name" is required',
//   'Field "User.age" not of expected type: number'
// ]

The result ​

MemberTypeMeaning
validbooleantrue when there are no issues
issuesValidationIssue[]one { code, path, message, params } per problem
errorsstring[]one line per issue, the message with its location; rendered on first use
summarystringthe error lines joined with newlines
flatten(){ formErrors, fieldErrors }messages by dotted path, for forms
tree(){ errors, properties?, items? }messages as a tree that follows the value
toJSON(){ valid, issues }what JSON.stringify(result) writes

An issue is data. path is the array of keys from the root of the value (a key, or an index for an array, set or tuple element), message describes the problem without its location, and params carries what the message was made from:

js
user.check({ age: 'old' }).issues;
// [
//   { code: 'required', path: ['name'], message: 'is required', params: {} },
//   {
//     code: 'type',
//     path: ['age'],
//     message: 'not of expected type: number',
//     params: { expected: 'number', received: 'string' },
//   },
// ]

The codes the library produces, and their params:

CodeMeaningparams
requireda required field is missing, or a required object/map is empty{}
typethe value is not of the field's type (also a message from a custom type's checkType){ expected, received }, plus key when a key of an object or map has the wrong type
unexpectedkeys the schema does not have; one issue per struct (and per flags value){ keys }, all of them; the message lists the first ten
enumthe value is not one of the enum values{ values }
lengtha length rule failed, or a tuple has too many elements{ min, max, actual }
rangea min or max rule failed{ min, max, actual }
patterna pattern rule failed{ pattern }, the source of the expression
formata format rule failed{ format }, the name of the format
decimalsa decimals rule failed: more digits after the point, or not a finite number{ decimals }
unionno branch of a union matched; with a discriminator, the path ends with it{ expected, discriminator }: the branch names, or the discriminator values
referencethe referenced entity is not in any attached model, nor the schema's own name{ entity }
circularthe value refers back to itself through a reference{}
depthembedded records are nested deeper than maxDepth (128 by default){ max }
exceptiona validate or checkType function threw{ error }
customa message from a validate functionwhatever the function gave, or {}

errors adds the location to each message: Field "<root><path>" <message>, where the path is rendered in dotted form (name.first, tags[2], companies[1].name, ["a.b"] for a key that is not an identifier) after the root, which is the schema name or the root option. A message that already starts with Field (the convention of custom types) is kept as it is.

For a form, flatten() groups the messages by dotted path without the root, and tree() follows the value:

js
const form = Schema.from({ name: 'string', tags: { array: { label: 'string' } } });
const result = form.check({ name: 1, tags: [{ label: 'ok' }, { label: 2 }], extra: true });

result.flatten();
// {
//   formErrors: ['has unexpected keys: extra'],
//   fieldErrors: {
//     name: ['not of expected type: string'],
//     'tags[1].label': ['not of expected type: string'],
//   },
// }

result.tree().properties.tags.items[1];
// { errors: [], properties: { label: { errors: ['not of expected type: string'] } } }

Options ​

OptionDefaultMeaning
rootthe schema namethe label the error lines start with; '' for none. Issue paths never include it
maxErrorsunlimitedstop collecting after that many issues (a whole number of at least 1), and stop walking fields, elements and records as soon as the limit is reached
maxDepth128how deep a value may nest through embedded references; a record past that depth is a depth issue (is nested deeper than 128 references) instead of a stack overflow; see References
unknownthe schema's unknown metadata, 'reject' unless setwhat to do with keys the schema does not have: report them as one unexpected issue per struct, or 'ignore' them at every depth
references'kind'how a reference is checked: by the kind of its target and the field's embed, every reference as the record ('embed'), or every reference as an id ('id')
messagesEnglishthe locale of the messages, or a function that renders every message
js
user.check({}, { root: 'body' }).errors; // [ 'Field "body.name" is required' ]
user.check({}, { maxErrors: 1 }).errors; // [ 'Field "User.name" is required' ]
user.check({ name: 'Marcus', extra: true }, { unknown: 'ignore' }).valid; // true
person.check(payload, { references: 'embed' }); // the whole graph, stored records included

check answers with a verdict and never changes the value: no defaults, no decoding, no stripping. parse does those over the same check and returns the value of the program, and checkAsync is check for validators that return a promise.

check collects every problem it finds instead of stopping at the first one, and it never throws because of the value. An exception means the definition is broken (for example, an unknown type), and is raised when the schema is built, not when data is checked. It is a SchemaDefinitionError: a TypeError with a code and the schema and field it was found in:

js
new Schema('Order', { total: 'strng' });
// SchemaDefinitionError: Unknown type "strng" in "Order.total"
//   code: 'ERR_UNKNOWN_TYPE', schema: 'Order', field: 'total'

An option with a value it does not accept also throws a TypeError, as does a string in place of the options (the second argument of check was the root path in 1.x), and so does a validate function or a custom type's checkType that returns a promise: the code of that error is ERR_ASYNC_VALIDATOR, and checkAsync is the call that awaits them.

Messages and locales ​

Messages are rendered once, at the end of a check, from the code and the params of each issue through a locale: a table of one renderer per code. English is built in, Ukrainian ships as @alexify/metaschema/locales/uk:

js
const uk = require('@alexify/metaschema/locales/uk');

user.check({ age: 'old' }, { messages: uk }).errors;
// [
//   'Поле "User.name" є обовʼязковим',
//   'Поле "User.age" не відповідає типу: number'
// ]

A locale is a plain object, so one of your own can be partial (missing codes fall back to English) or you can render every message yourself with a function:

js
user.check({}, { messages: { required: () => 'please fill this in' } }).errors;
// [ 'Field "User.name" please fill this in' ]

user.check({ age: 'old' }, { messages: (issue) => `${issue.code}!` }).errors;
// [ 'Field "User.name" required!', 'Field "User.age" type!' ]

A renderer gets the params of the issue and the issue itself; field(path) renders the location prefix of an error line. The text a validate function returns is kept as it is.

Custom validation ​

On a field ​

Give a field a validate(value, path) function in the long form:

js
const schema = Schema.from({
  password: {
    type: 'string',
    validate: (value) => value.length >= 8 || 'must be at least 8 characters',
  },
});

A field is checked in order: its type, then its rules (length, pattern, format, min, max), then validate. A type failure is reported once and cancels the rest, and validate runs only on a value that passed everything before it, so it can rely on the type and the rules. path is the dotted path of the field, including the root. The function can return:

Return valueResult
true, null or undefinedvalid
false'validation error'
a stringthat message, with code custom
{ code, message, path?, params? }that message with your own code and params; path (a key or an array of keys) is relative to the field
an array of strings or { code, message } objectsone issue per entry
a ValidationResultits issues, relative to the field

ValidationResult is exported, so a function can build the result it returns:

js
const { Schema, ValidationResult } = require('@alexify/metaschema');

const schema = Schema.from({
  range: {
    type: 'string',
    validate: (value) => {
      const result = new ValidationResult();
      if (!value.includes('-')) result.add('needs a dash');
      if (value.length > 9) result.add('is too long');
      return result;
    },
  },
});

schema.check({ range: '0123456789' }).errors;
// [ 'Field "range" needs a dash', 'Field "range" is too long' ]

If the function throws, the error becomes an exception issue (validation failed Error: ...) instead of propagating. A function that returns a promise of one of these is awaited by checkAsync; check throws for it.

On a schema ​

A top-level validate function checks the whole value, for rules that span several fields. It runs after the fields, and only when every field passed, so it can rely on their shape. A relative path in the returned issue points at the field it is about:

js
const signup = Schema.from({
  password: { type: 'string', validate: (value) => value.length >= 8 || 'must be at least 8 characters' },
  confirm: 'string',
  validate: (value) =>
    value.password === value.confirm || { message: 'does not match', path: 'confirm' },
});

signup.check({ password: 'secret', confirm: 'other' }).errors;
// [ 'Field "password" must be at least 8 characters' ]
signup.check({ password: 'secretsecret', confirm: 'other' }).errors;
// [ 'Field "confirm" does not match' ]

schema.validate(value, path?) runs only that function and returns null when the schema has none.

Patterns and ReDoS ​

A pattern runs a regular expression over input you do not control. An expression with nested or overlapping repetition (^(a+)+$, ^(\w+\s?)*$) backtracks exponentially on a crafted string, and one such field is enough to stall the process: a regular expression denial of service. Two habits keep a schema safe:

  • Bound the input. Give every pattern field a length.max. A string longer than the bound is reported for its length and the pattern is never run over it (with unicode: true the length counts code points and the pattern may be run over at most twice the bound in UTF-16 units), so backtracking is bounded by the input you allow, and a bound of a few hundred characters keeps even a bad expression fast. A schema with a pattern and no length.max is reported in schema.warnings. A format is run under the same bound.
  • Write linear patterns. Avoid a quantifier over a group that itself contains a quantifier, and alternatives that can match the same text ((a|a)*). Anchor with ^ and $, and prefer character classes ([a-z0-9-]+) over .*.

metaschema compiles the pattern once and runs RegExp#test on the value, nothing more; it does not analyse the expression. A pattern that needs lookbehind or a long alternation is often better written as a validate function with explicit checks.

Calculated fields ​

A function as a field value is a calculated field. It is part of the definition but never validated, so the data does not need to contain it:

js
const file = Schema.from({
  size: 'number',
  compressed: 'number',
  ratio: (value) => value.compressed / value.size,
});

file.check({ size: 100, compressed: 25 }).valid; // true
file.fields.ratio({ size: 100, compressed: 25 }); // 0.25

Released under the MIT License.