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).
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.
| Form | Example | Meaning |
|---|---|---|
| Type name | title: 'string' | a required string |
| Optional type | subtitle: '?string' | undefined or null is allowed |
| Optional key | 'tags?': { array: 'string' } | the same, written on the key |
| Nullable | parent: { type: 'string', nullable: true } | the key is required, the value may be null |
| Long form | size: { type: 'number', required: false } | a type with options |
| Type shorthand | list: { array: 'number' } | the key of the object is the type |
| Nested struct | name: { first: 'string', last: 'string' } | an object with its own fields |
| Tuple | point: ['number', 'number'] | a fixed-length array, each element its own definition |
| Union | id: { union: ['string', 'number'] } | one of several definitions |
| Reference | company: 'Company' | a capitalized name is another schema |
| Aliases | login: { type: 'string', aliases: ['name'] } | a field of a struct is read from name as well (elsewhere warnings says dead-aliases); see Model evolution |
| Function | ratio: (file) => file.compressed / file.size | a calculated field, never validated |
Fields are required unless marked optional. A struct rejects keys it does not declare:
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 key | The object is | Example |
|---|---|---|
a Schema instance (no keys) | that schema, as a nested struct | name: fullName |
| capitalized | a kind with its metadata | { Entity: {}, ... } |
type | the long form | { type: 'string', length: 8 } |
| a type name | that type's shorthand | { array: 'string' } |
| anything else | a 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:
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:
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:
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:
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:
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:
Schema.from('string').kind; // 'scalar'
Schema.from('?number').check(null).valid; // true
Schema.from(['string', 'number']).check(['x', 1]).valid; // trueMerging 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.
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:
| Key | Merged as |
|---|---|
type | the same type; any or unknown and a type is that type; integer and number is integer; anything else is a conflict |
required | required where either requires it |
nullable | null allowed only where both allow it |
length, min, max | the greater of the minimums and the lesser of the maximums; nothing in common (a minimum above a maximum) is a conflict |
enum | the 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 reference | the same, or one side only; two different ones are a conflict |
validate | both, composed: the check gets what each returned, awaited when either is async |
aliases | the 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, deprecated | set 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 tuple | merged 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.