Skip to main content

Schema Compilation

The Schema class is only used for schema definition. They act as a "builder" with a fluent API, and must be compiled into CompiledSchema instances to be usable for runtime processing.

Compilation recursively processes each schema in the hierarchy:

  • Flatten inherited base schemas, resolving named schemas using the SchemaResolver.
  • Normalize defaults and values.
  • Synthesize implicit union discriminators.
  • Synthesize conditions for selectors.
  • Convert each handler's value processor specs into functions, resolving named keywords using the SchemaResolver.
  • Render value descriptions to metadata.
  • Perform consistency checks.

As a "dogfooding" exercise to demonstrate the expressiveness of the library, compilation is itself implemented as a schema1 that converts an input Schema to an output CompiledSchema!

Compilation Cache

Each schema compiled is cached2 both for efficiency as well as enabling circular definitions (e.g. "a folder is a list of files or folders"). However, because every inherited schema is flattened, two identical-looking properties are separate instances that can't take advantage of the caching:

import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();

function compare(name, schema) {
console.log(`${name} child schemas equal: ${schema.getPropertySchema('src') === schema.getPropertySchema('dst')}`)
}

// src and dst are compiled separately, including the string base schemas:
const schema1 = await resolver.compile(
new Schema('object')
.property('src', new Schema('string').validator('$uuid'))
.property('dst', new Schema('string').validator('$uuid'))
)

compare('schema1', schema1); // -> schema1 child schemas equal: false

// whereas here the common schema is only compiled once:
const uuidSchema = new Schema('string').validator('$uuid');
const schema2 = await resolver.compile(
new Schema('object')
.property('src', uuidSchema)
.property('dst', uuidSchema)
);

compare('schema2', schema2); // -> schema2 child schemas equal: true

// it would be nice if this was equally efficient, but we're back to separate instances:

resolver.registerSchema('my-uuid', uuidSchema);
const schema3 = await resolver.compile(
new Schema('object')
.property('src', new Schema('my-uuid'))
.property('dst', new Schema('my-uuid'))
);

compare('schema3', schema3); // -> schema3 child schemas equal: false

Name Resolution

The SchemaResolver is a key component in the compilation process, resolving both schema names and value processor keywords from its internal registries. It also handles recursively flattening base classes via its resolve() method.

import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
const n = new Schema('number').validator('$positive');

// Here's the simple data representation of a Schema:
console.log( n.toData() );
/*
{ base: 'number', handlers: { validators: [ '$positive' ] } }
*/

// After resolution, the schema has been flattened:
console.log( resolver.resolve(n).toData() );
/*
{
metadata: { valueName: 'number', parserTypeHint: 'number' },
options: { type: 'number' },
handlers: {
validators: [ '$is-number', '$positive' ],
normalizers: [ '$number' ]
}
}
*/

// After compilation, the schema resolves value processors and keywords
// into functions, but it preserves the original names so that the
// CompiledSchema can provide a compatible data shape for use as a
// base schema:
console.log( (await resolver.compile(n)).toData() );
/*
{
metadata: {
valueName: 'number',
parserTypeHint: 'number',
validatorDescription: 'positive',
valueDescription: '[positive]'
},
options: { type: 'number' },
handlers: {
validators: [ '$is-number', '$positive' ],
normalizers: [ '$number' ]
}
}
*/

Footnotes

  1. The SchemaCompiler class wrapped by SchemaResolver is a subclass of CompiledSchema that acts as sort of "Schema Schema", and the SchemaResolver.compile() method is just a thin convenience wrapper around the schema process() call.

  2. Caution: the input schema value is currently used as the cache key, which means that any changes to the input schema internals will be ignored if you compile it a second time! Addressing this is on the roadmap, but for now, always make a copy of a schema that has been compiled if you need to make an extended version.