Skip to main content

Built-in Base Schemas

When you create a schema like new Schema('string'), you're creating a schema that inherits from the registered base schema named 'string'. These base schemas define the fundamental processing behavior (normalization, transformation, validation, serialization).

Built-in Base Schema Names

  • string
  • number
  • boolean
  • object
  • array
  • date
  • buffer

More complex objects should be defined using object schemas with properties, or by registering custom base schemas with the SchemaResolver.

Schema Inheritance

Schemas can inherit from other schemas using the base parameter:

// Inherit from the built-in 'string' base schema
const emailSchema = new Schema('string')
.validator('$email')
.meta('description', 'Email address');

// Or inherit from a custom registered schema
const customSchema = new Schema('myCustomBase')
.property('extra', new Schema('string'));

The first parameter to new Schema() can be:

  • A string name of a registered base schema (e.g., 'string', 'number')
  • Another Schema instance to inherit from directly

Arrays and Member Schemas

Array schemas can define schemas for their members using the wildcard property '*':

// Basic array without member schema
const listSchema = new Schema('array');

// Array with member schema (all members must be numbers)
const numbersSchema = new Schema('array')
.property('*', new Schema('number'));

Providing --list 1 2 3 --numbers 1 2 3 will normalize the values according to their member schemas:

{
"list": ["1", "2", "3"],
"numbers": [1, 2, 3]
}

Object-like Schemas

When a schema has child properties defined, it will build up its value incrementally; first constructing an empty "container" object (via the base object schema transformer), then calling the value handlers on each child schema, and finally individually assigning these values to the children.

The assignment sequence for the above object and schema is essentially:

output = {};
output.enabled = true;
output.user = {};
output.user.firstName = 'John';
output.user.lastName = 'Doe';

If an object schema does not have child properties defined, the schema value handlers will receive the entire object at once, and the contents are considered "opaque".

Array-like Schemas

The same behaviors hold true with schemas that extend array. However, arrays can define their "index" child property behavior with a "wildcard" as the property name:

// matches an array containing at least one alphanumeric string
const a = new Schema('array').required().validator({$length: {$min: 1}})
.property('*', new Schema('string').validator('$alphanum'))

Alternatively, you can define a tuple by using numeric properties:

const location = new Schema('array')
.meta('description', 'location')
.property('0', new Schema('number')
.required()
.validator({$range: {$min: -90, $max: 90}})
.meta('valueName', 'latitude')
)
.property('1', new Schema('number')
.required()
.validator({$range: {$min: -180, $max: 180}})
.meta('valueName', 'longitude')
)

Custom Normalizers

Normalizers allow friendly user inputs to be converted to standardized configuration values. For example, here's a duration schema that accepts --timeout 1m30s but outputs milliseconds:

import { SchemaResolver } from '@versionzero/configurator';

const resolver = new SchemaResolver();

// Register a duration base schema with custom normalizer
resolver.registerType('duration', value => {
if (typeof value === 'number') {
if (value < 0 || !Number.isFinite(value)) throw new Error(`Duration ${value} is not a valid number`);
return value;
}
if (typeof value === 'string') {
const units = { h: 3600000, m: 60000, s: 1000, ms: 1 };
let total = 0;
value.replace(/(\d+)(h|m|s|ms)?/g, (_, num, unit) => {
total += +num * (units[unit] || 1);
});
if (total === 0) throw new Error(`Invalid duration ${value}`);
return total;
}
throw new Error(`Duration ${value} must be number or string`);
});

// Use it in a schema
const schema = new Schema('object')
.property('timeout', new Schema('duration'));

// Compile with the custom resolver
const compiled = resolver.compile(schema);

Transformers

While normalizers convert input strings to the appropriate type, transformers can modify values during processing and access the current configuration state:

const schema = new Schema('object')
.property('format', new Schema('string')
.validator({$in: ['text', 'json']})
.required()
)
.property('formatter', new Schema('object')
.transformer((value, config, schema) => {
// Transform based on other config values
if (config.format === 'text') {
return new TextFormatter();
} else if (config.format === 'json') {
return new JsonFormatter();
}
return value;
})
.default({}) // Provide a default to trigger evaluation
);

Transformers receive the current value, the configuration object being built, and the schema. This allows for dynamic behavior based on other configuration values.

Array Transformers

Array schemas can have transformers that process the entire array or its members:

const schema = new Schema('object')
.property('plugins', new Schema('array')
.property('*', new Schema('string'))
.transformer((list, config) => {
// Transform the entire array
if (list.includes('*')) {
return getAllAvailablePlugins();
}
// Ensure uniqueness
return [...new Set(list)];
})
.default(['*'])
);

Serializers

Serializers convert processed values back to a format suitable for output (e.g., for --dump). This is optional and primarily used when you want to output configuration in a readable format.

For example, a timestamp schema that normalizes to milliseconds but serializes to ISO strings:

const timestampNormalizer = (value) => {
if (typeof value === 'number') {
if (value < 0) {
throw new Error(`Invalid negative timestamp value: ${value}`);
}
return value;
}
else if (!value || value === 'now') {
return Date.now();
}
else if (typeof value === 'string') {
let t = new Date(value).getTime()
if (isNaN(t)) {
throw new Error(`Invalid timestamp value: ${value}`);
}
return t;
}
throw new Error(`Invalid timestamp value: ${value}`);
};

const timestampSerializer = (value) => {
return new Date(value).toISOString();
};

// Register as a base schema
resolver.registerType('timestamp', timestampNormalizer, { serializer: timestampSerializer });

// Or use directly in a schema
const schema = new Schema('object')
.property('createdAt', new Schema('number')
.normalizer(timestampNormalizer)
.serializer(timestampSerializer)
);