MURAL schema is a simple way of validating JSON objects, using pure JSON objects without all the clutter of JSON Schema.
npm i mural-schema
In the context of MURAL schema a type can be any of the following:
- built-ins:
string
boolean
number
undefined
null
RegExp
- an object mapping keys to other types (e.g.
{ "a": "string" }
). - an array of types
- a union of types
- a literal type
- a validation function
- a custom type
- optional types
- partial and recursive partial types
- keyof types
We'll talk about each of these in the next sections.
import { parseSchema } from 'mural-schema';
const schema = // define the schema
const options = {}; // you can omit this argument
const validate = parseSchema(schema, options);
// do note that `parseSchema` will throw if `schema` is invalid
// (e.g. it references unknown types)
const input = // some input
const errors = validate(input);
if (!errors.length) {
// success
} else {
// failure
}
'string'
,'boolean'
,'number'
,undefined
,null
,any
To represent a built-in, just use the string name of the type in the schema.
For example:
// a string
'string'
// an object whose `s` key must be a string
{ s: 'string' }
// a boolean
'boolean'
// a number
'number'
// undefined
undefined
// null
null
// anything
'any'
/^\d{3}-\d{5}$/
When the schema's type is a RegExp, the value must be a string that matches that RegExp.
Note: consider using anchors in the RegExp (e.g.
/\A...\z/
or/^...$/
) to avoid unexpected values
For example:
// a string containing at least one number
/\d+/
// exactly the same as above
/\d/
// what you probably meant with the two previous ones (as string
// containing at least one number and only numbers)
/^\d+$/
{ key1: Type, key2: Type, ..., [$strict: false], $any: Type }
A schema type can be an object with arbitrary keys and types in the values.
Since any type is allowed in the object's value, nested objects can be modeled by nesting object schemas.
Note: the special key/value
$strict: false
can be specified in object schemas to allow extra keys to be present in the input object. By default the input object cannot have any unknown key.
Sometimes an object type has random key names but a specific known value for
each key. This scenario can be modelled using the $any
key name which
matches any key in the object and asserts the value of every key in the
object.
For example:
// an object whose `s` key must be a string
// Note: when validating { s: 'a', other: 1 } the validation fails
{ s: 'string' }
// an object whose `s` key must be a string, and can have any other
// keys/values
// Note: when validating { s: 'a', other: 1 } the validation passes
{ s: 'string', $strict: false }
// an object with a `child` object containing a number in `a`
{ child: { a: 'number' } }
// an dictionary object where keys can be string but their values must be
// numbers
{ $any: 'number' }
// idem, but this time, the values are persons
{ $any: { firstName: 'string', lastName: 'string' }
[Type]
or[Type1, Type2, ...]
A schema type can be an array types.
When the schema array contains a single type, every element in the input array must match this type (i.e. homogeneous array).
When the schema array contains multiple types, the element in the input array can be of any of the array types (i.e. heterogeneous array).
For example:
// an array of numbers (e.g. [1, 2, 3, 4])
['number']
// an array of numbers or strings (e.g. [1, 'two', 3, 4])
['number', 'string']
'string|number|MyCustomType'
or[['boolean', { a: 'string' }, OtherType]]
Union schema types are the type-equivalents of the OR
operator. A union type
is a set of types. The input value must match at least one of the types
included in the union type.
There are two flavours for union types: string unions and array unions.
String unions can be used only with string types (i.e. built-ins and custom types).
Array unions are a generalization of the above that can be used with any set of types, at the expense of some syntactic noise. Unlike string unions, array unions can also be used with objects, functions, RegExps, etc.
For example:
// a number or string (string union)
'number|string'
// same as above (array union)
[['number', 'string']]
// a number or an object with a `name` string and `value` number
[['number', { name: 'string', value: 'number' }]]
1
,false
,'"good"'
,"'good'"
,'`good`'
,'#good'
,'#red|#green'
, etc.
A literal type is a schema type defined by a single specific value. The input value must have the exact value defined by the literal type.
Note: string literals can either be quoted with
"
,'
or`
, or prefixed with a#
to distinguish them from built-in and custom types (e.g.'"number"'
,"'number'"
,'`number`'
and'#number'
represent the constant value number, while'number'
represents a numeric value).
Note: when combining literal types with a union type you can create an enumeration type, that is, a type that describes an input value that must be one of a pre-defined set of values.
For example:
// a constant value 1
1
// a constant string 'good' (all examples below are interchangeable)
'"good"'
"'good'"
'`good`'
'#good'
// a value that must be either 'red' or 'green' (enumeration type)
'#red|#green'
(obj: any) => boolean
or(obj: any) => ValidationError[]
A function schema type is a function that takes the input values and returns
either a boolean (i.e. true
for success, false
for failure), or an array
of validation errors (i.e. empty array for success).
For example:
// crude email validation
obj => typeof obj === 'string' && obj.includes('@')
// shout validation with custom error message
import { expected, error } from 'mural-schema/util';
obj => {
if (typeof obj !== 'string') return [expected('', 'string')];
return obj.endsWith('!')
? [] // success
: [error('', 'Expected a shout with trailing `!`')];
};
options: { customTypes: { name: Type, ... } }
You can register custom types to be used for schema validation. Custom types are actually aliases for other types.
Custom types are passed as part of the options
argument to parseSchema
.
For example:
const options = {
customTypes: {
email: obj => typeof obj === 'string' && obj.includes('@'),
},
};
const schema = { billingEmail: 'email' };
const fn = parseSchema('body', schema, options);
const errors = fn({ billingEmail: 'not an email' });
// errors = [{ message: 'Expected email', key: 'body.billingEmail' }]
'string?'
,'MyCustomType?'
or[[Type, undefined]]
Optional types are types whose value can also be undefined
. There are three
flavours of optional types: optional object keys, optional strings and
optional unions`.
Optional object keys are the most frequent optional type, and likely the only
one you'll ever need. Given an object type { "key": Type }
you can make key
optional by appending a ?
like: { "key?": Type }
.
Given a string type T
(e.g. number
), you can always make it optional by
appending a ?
to the type (e.g. number?
).
For complex types (e.g. objects, arrays, functions, etc.) you can simulate an
optional type T
as a union of T
and undefined
.
For example:
// some optional object keys
{
// see the `?` suffix in the key
'objectKeyOptional?': 'string',
// same but with the `?` suffix in the value
'stringTypeOptional': 'string?',
// the nice thing about putting the `?` in the keys is that it allow complex
// optionals that not just type name strings, such as children schemas:
'superComplexOptional?': { a: 'string' },
}
// an optional string
'string?'
// an optional custom type (assuming { email: obj => ... })
'email?'
// an optional object type using union syntax
[[{ name: 'string' }, undefined]]
{ 'key/': { a: 'string' } }
,{ 'key//': { a: { b: 'string' } } }
Partial types are types whose value must be a subset of an object. The partial
key modifier /
marks the value of that key as a shallow partial object. That
is, the value can contain zero or more of the object properties. The recursive
partial key modifier //
applies the partial operator recursively to all
descendant keys of type object.
Note that if combined with the optional key modifier ?
, the partial modifier
should always precede the optional modifier as in /?
or //?
.
For example:
// some partial object keys
{
// see the `/` suffix in the key
'obj/': {
a: 'string',
b: 'string',
},
}
// accepts `{ obj: { a: '!' } }`
{
// recursive partial, see the `//` suffix
'obj//': {
a: {
b: 'string',
c: 'string',
},
d: 'string',
},
}
// accepts `{ obj: { a: { b: '!' } } }`
// partial optional
{
// see the `/?` suffix in the key
'obj/?': {
a: 'string',
b: 'string',
},
}
// accepts `{}`, `{ obj: {} }`, etc.
{ 'key:keyof': { a: 'string' } }
,{ $keyof: { a: 'string' } }
Keyof types are types whose value must be the name of an object property.
Lets say we have an object type A
that defines properties a
and b
(their
types are not relevant to this description). The type of the key names of A
would be '"a" | "b"'
. If we wanted a type KA
that represents the name of a
key of A
we could just write:
const KA = '"a"|"b"';
...but if we later add a new key to A
that KA
would be out of sync. In order
to avoid all this trouble, you could just write:
const KA = { $keyof: A };
If you are putting this keyof as the value of an object's property, that is:
const SomeObject = {
aKeyOfA: { $keyof: A },
};
...you could also opt for using the :keyof
suffix like this:
const SomeObject = {
'aKeyOfA:keyof': A,
};
For example:
const A = { a: 'string', b: 'string' };
const KA = { $keyof: A }; // accepts `'a'` and `'b'`, rejects everything else
// Note that the following are equivalent
const SomeObject1 = {
'aKeyOfA:keyof': A,
};
const SomeObject2 = {
aKeyOfA: { $keyof: A },
};
const SomeObject3 = {
aKeyOfA: '"a"|"b"',
}
// they all accept `{ aKeyOfA: 'a' }` and `{ aKeyOfA: 'b' }`
// and reject everything else
// A more contrived example
const ArrayOfKeysOfA = [{ $keyof: A }]; // a list of keys of A
// accepts `[]`, `['a']`, `['b']`, `['a', 'b']`, `['a','a','a']`, etc.
// rejects `['c']`
Finally, if you enjoy formal violence, here is a sort-of-EBNF summarizing most of the above.
Type = Scalar | Array | Union | Function;
Scalar = Object | Simple;
Object = '{' , KeyValue , {',' , KeyValue} , '}';
KeyValue = Key , ':' , Type;
Key = string , { ':keyof' } , { '/' , { '/' } }, { '?' } | '$any'
Simple = string | RegExp | undefined | null;
Array = '[' , Type , {',' , Type} , ']';
Union = StringUnion | ArrayUnion;
StringUnion = string , {'|' , string};
ArrayUnion = '[[' , Type , {',' , Type} , ']]';
Function = ValidationFn | CheckFn;
ValidationFn = '(obj: any) => ValidationError[]';
CheckFn = '(obj: any) => boolean';
If you want to contrib or are curious about how all this works, you can keep reading our INTERNALS (i.e. gory details) document.