Skip to content

Schema Syntax ​

A schema is a plain JavaScript value. Schema.from(definition) builds an anonymous schema, and new Schema(name, definition) builds a named one (the name is used in error paths and in generated TypeScript).

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

const anonymous = Schema.from({ title: 'string' });
const named = new Schema('Article', { title: 'string' });

Fields ​

Every key of a definition object is a field, and its value says what the field holds.

FormExampleMeaning
Type nametitle: 'string'a required string
Optional typesubtitle: '?string'undefined or null is allowed
Optional key'tags?': { array: 'string' }the same, written on the key
Nullableparent: { type: 'string', nullable: true }the key is required, the value may be null
Long formsize: { type: 'number', required: false }a type with options
Type shorthandlist: { array: 'number' }the key of the object is the type
Nested structname: { first: 'string', last: 'string' }an object with its own fields
Tuplepoint: ['number', 'number']a fixed-length array, each element its own definition
Unionid: { union: ['string', 'number'] }one of several definitions
Referencecompany: 'Company'a capitalized name is another schema
Aliaseslogin: { type: 'string', aliases: ['name'] }a field of a struct is read from name as well (elsewhere warnings says dead-aliases); see Model evolution
Functionratio: (file) => file.compressed / file.sizea calculated field, never validated

Fields are required unless marked optional. A struct rejects keys it does not declare:

js
const schema = Schema.from({ title: 'string' });
schema.check({ title: 'Meditations', author: 'Marcus' }).errors;
// [ 'Field "" has unexpected keys: author' ]

The first key decides ​

An object definition is read by its first key, the first key that starts with a letter (so { _id: 'string', type: 'string' } is read by type), and nothing else:

First keyThe object isExample
a Schema instance (no keys)that schema, as a nested structname: fullName
capitalizeda kind with its metadata{ Entity: {}, ... }
typethe long form{ type: 'string', length: 8 }
a type namethat type's shorthand{ array: 'string' }
anything elsea nested struct{ first: 'string', last: 'string' }

So { name: 'string', type: 'string' } is a struct with the fields name and type, while { type: 'string', name: 'string' } is a string whose definition carries a name option. A struct whose first field is called type, or named like a type, says so with a kind or with the schema shorthand:

js
Schema.from({ Struct: {}, type: { enum: ['cpu', 'ram'] }, name: 'string' });
Schema.from({ part: { schema: { type: { enum: ['cpu', 'ram'] }, name: 'string' } } });

The type names include bytes and flags since 3.4, so a struct whose first field is called bytes or flags ({ flags: ['a'], name: 'string' }) is read as that type now and needs a kind; schema.warnings names the options such a misread leaves unknown.

An object that cannot be what its first key says is a SchemaDefinitionError (ERR_INVALID_DEFINITION): { required: false, type: 'string' } is read as a struct, and false is not a field definition.

The long form ​

{ type: '<name>', ...options }, with type first, gives a field options next to its type:

js
const schema = Schema.from({
  login: { type: 'string', length: { min: 3, max: 32 } },
  email: { type: 'string', unique: true, validate: (value) => value.includes('@') },
  nickname: { type: '?string' },
  bio: { type: 'string', required: false },
});

Keys a type does not understand (unique above) are kept on the field as metadata. They do not affect validation, but they are there for code that reads the schema, such as a database layer. The annotations default, unique, index, primary, title, description, examples, deprecated and aliases are expected, and so are parse and serialize, the codec functions of a field (default is applied by parse); any other key the type does not read is reported in schema.warnings as unknown-option, which catches a mistyped option (lenght) before it is silently ignored.

Type shorthands ​

When the first key of an object is a type name, the object describes that type and its value is the type's argument:

js
Schema.from({
  tags: { array: 'string' },
  matrix: { array: { array: 'number' } },
  scores: { object: { string: 'number' } },
  ids: { set: 'number' },
  role: { enum: ['admin', 'user'] },
});

The long form of the same fields uses type plus a key named after the type: { type: 'array', array: 'string' }.

Because the first key decides, a struct whose first field is named like a type (map, set, string, json, or a custom type) is read as that type: Schema.from({ map: {...}, name: 'string' }) is a schema of type map with a name option, not a struct with a map field. Give such a struct a kind ({ Struct: {}, map: {...}, name: 'string' }), use the schema shorthand, or put another field first.

Nested structs ​

An object whose first key is neither a type nor a kind is a nested struct:

js
const schema = Schema.from({
  name: { first: 'string', last: 'string', third: '?string' },
  address: { city: 'string', street: 'string' },
});
schema.check({ name: { first: 'Marcus' }, address: { city: 'Rome', street: 'Via Appia' } }).errors;
// [ 'Field "name.last" is required' ]

A Schema instance can be used as a field too, bare or in the schema forms, and it brings its fields, its references and its schema-level validate with it:

js
const fullName = Schema.from({ first: 'string', last: 'string' });
const person = Schema.from({
  name: fullName,
  'nickname?': fullName,
  alias: { schema: fullName, required: false },
  age: 'number',
});

Kinds ​

A capitalized first key sets the schema's kind and its metadata, for example { Entity: {}, ... } or { Registry: { scope: 'global' }, ... }. Kinds matter for domain models; see Kinds and Metadata.

Scalars and tuples on their own ​

A definition does not have to be an object:

js
Schema.from('string').kind; // 'scalar'
Schema.from('?number').check(null).valid; // true
Schema.from(['string', 'number']).check(['x', 1]).valid; // true

Merging schemas ​

merge(a, b, options) of @alexify/metaschema/model (Schema.merge after its register()) is the schema both schemas describe, by unification, the way CUE composes definitions instead of inheriting them: a field in both takes the constraints of both, a field in one comes as it is, and what no value can satisfy is an error.

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

const base = new Schema('Contact', { name: { type: 'string', length: { min: 1 } }, email: 'string', 'phone?': 'string' });
const strict = Schema.from({
  name: { type: 'string', length: { max: 64 } },
  email: { type: 'string', pattern: '@', length: 254 },
  phone: { type: 'string', length: { min: 7 } },
});

const contact = merge(base, strict);
contact.fields.name.length; // { min: 1, max: 64 }
contact.fields.phone.required; // true: required in one is required
contact.check({ name: 'Ann', email: 'ann@example.com' }).errors; // [ 'Field "Contact.phone" is required' ]

merge(base, Schema.from({ email: 'number' }));
// throws SchemaDefinitionError [ERR_MERGE_CONFLICT]: The type is string in one schema and number in the other in "Contact.email"

A conflict is located like a definition error: error.schema is the name of the merged schema, error.field the path of the field (address.city for a nested one, '' when the schemas as a whole cannot be merged), and the message ends with both.

The rules, per key of a field in both schemas:

KeyMerged as
typethe same type; any or unknown and a type is that type; integer and number is integer; anything else is a conflict
requiredrequired where either requires it
nullablenull allowed only where both allow it
length, min, maxthe greater of the minimums and the lesser of the maximums; nothing in common (a minimum above a maximum) is a conflict
enumthe values both hold; none is a conflict
pattern, format, default, parse, serialize, union, discriminator, the key type of an object or map, the target and embed of a referencethe same, or one side only; two different ones are a conflict
validateboth, composed: the check gets what each returned, awaited when either is async
aliasesthe aliases of both; an alias that is a field of the other schema, or an alias of another field, is a conflict
unique, index, primary, deprecatedset where either sets it
a nested struct (schema)its fields merged the same way; a struct and a schema of one type are a conflict
the element of a collection, the elements of a tuplemerged the same way; tuples of another length are a conflict
any other key (title, description, examples, an option of a custom type)the first schema's

A calculated field or a parse/serialize of the schema itself must be the same function on both sides; the schemas' own validate functions compose. The indexes are the ones of either, the same under one key. The kind and its metadata are the first schema's, and so is the name, unless options.name or options.metadata ({ kind, scope, store, allow, unknown, ...custom } over the first schema's) say otherwise. The inputs are not changed; the result is a new Schema with the namespaces of both.

Released under the MIT License.