/mural-schema

A simple way of validating JSON objects, using pure JSON objects

Primary LanguageTypeScriptMIT LicenseMIT

MURAL schema

Summary

MURAL schema is a simple way of validating JSON objects, using pure JSON objects without all the clutter of JSON Schema.

Installation

npm i mural-schema

Descirption

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.

Usage

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
}

Built-ins

'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'

RegExp

/^\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+$/ 

Objects

{ 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' }

Arrays

[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']

Union types

'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' }]]

Literal type

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'

Validation function

(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 `!`')];
};

Custom types

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' }]

Optional types

'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]]

Partial types

{ '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.

Keyof types

{ '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']`

EBNF

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';

Internals

If you want to contrib or are curious about how all this works, you can keep reading our INTERNALS (i.e. gory details) document.