Architecture
Design Goals
The original motivation for this library was to support modular application configuration. This led to some specific requirements:
- Support processing "messy" inputs into validated outputs.
- Provide decoupled contracts between data sources and data consumers:
- application should not know (or depend on) module internals
- modules should not know (or make assumptions about) the application environment
- support dependency injection
- Provide a way to round-trip data.
- Support use of async value processing without making all processing async.
Core Concepts
Schema Definition
A schema defines data handling rules that enable two primary capabilities:
- validation: ensure that provided data matches the schema definition.
- processing: attempt to transform provided data to match the schema definition.
Compilation
The Schema class provides a friendly "fluent" builder API for defining rules. This
is then compiled into an immutable CompiledSchema that provides efficient runtime
processing. The compilation process is decoupled in order to allow schemas that contain
unresolved references to be aggregated together, and resolved late (by SchemaResolver)
before use.
See the compilation section for details.
Composition
Perhaps the most important nuance of the entire library is that there are no "schema types". Schema specialization is primarily based on composing pipelines of value processors.
Let's start from first principles. Here's the most basic schema:
import { Schema } from '@versionzero/schema';
const schema = new Schema();
It has no constraints, no opinions. It transparently passes input, and considers everything valid.
We can compile it and watch it do... nothing:
import { SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
const compiledSchema = await resolver.compile(schema);
await compiledSchema.process(123); // -> 123
await compiledSchema.process('hello'); // -> 'hello'
await compiledSchema.validate(NaN) // -> NaN
To create a schema that does something useful, we can compose behavior:
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
const myStringSchema = await resolver.compile(
new Schema()
.normalizer( value => `${value}` )
.validator( value => {
if (typeof value !== 'string') {
throw new Error('not a string')
}
return value;
})
);
await myStringSchema.process(123); // -> '123'
await myStringSchema.validate('Hello') // -> 'Hello'
await myStringSchema.validate(123); // -> throws error
The functions passed to the normalizer and validator handlers in this example are the simplest
form of value processor.
Extension and Reuse
You can reuse and extend a schema by passing a schema instance to the constructor.
Continuing from the previous example,
const myLowerStringSchema = new Schema(myStringSchema).normalizer( value => value.toLowerCase() )
const mlss = await resolver.compile(myLowerStringSchema);
await mlss.process(123); // -> '123'
await mlss.process('Hello'); // -> 'hello' (lower case now!)
await mlss.validate('Hello'); // -> 'Hello' (validation doesn't normalize)
await mlss.validate('123'); // -> '123'
await mlss.validate(123); // -> throws error
The SchemaResolver contains a registry that allows you to save schemas by name.
You can then use that name as the base for new schemas:
resolver.registerSchema('MLSS', myLowerStringSchema);
const mlss1 = await resolver.compile(new Schema('MLSS'));
await mlss1.process('Hello'); // -> 'hello'
You can also save processors for use as a keyword in processor pipelines.
resolver.registerValueProcessor('letters-only', value => {
if (!/^[^A-Za-z]+$/.test(value)) { throw new Error('must only be letters')}
return value;
}
);
const enforced = await resolver.compile(new Schema('MLSS').validator('$letters-only'));
await enforced.process('Hello'); // -> 'hello'
await enforced.validate('hello'); // -> 'hello'
await enforced.validate('Hello!'); // throws
By using named schemas and processor keywords, implementation details remain safely hidden, and a schema contract can be expressed in a simple declarative manner.
All prebuilt schemas implementing "fundamental types" are composed in this manner.
The string schema above is just a simpler version of the provided string, and there
is a prebuilt $alpha constraint that is useful as a validator:
new Schema('string').normalizer('$lowercase').validator('$alpha');
The entire suite is implemented using the same public API available to library users.
Structure
Schemas can define hierarchical structure using child properties, which are also schemas:
const serviceSchema = new Schema('object')
.property('host', new Schema('string')
.default('localhost')
.required()
)
.property('port', new Schema('number')
.default(3000)
.validator('$positive')
.validator('$integer')
)
Next:
- Read about the rationale