Types
Types in ConfigurationSchema are defined by named functional Type Resolvers rather than primitives or schema
aggregates. A Type Resolver receives an input value, and either:
- returns a valid configuration output value,
- throws an Error, or
- returns
undefinedif it cannot be resolved yet.
If a field assignment's type value resolver returns undefined, it is queued for a retry after working
through other assignments. Only when the configuration has "stabilized" and no new assignments have
succeeded is an undefined result considered final (a field with the required attribute set will then
throw an error!)
The Configurator has a default TypeRegistry, but you can also pass in your own.
import { Configurator, TypeRegistry } from '@versionzero/configurator';
const configurator = new Configurator();
const types = configurator.types;
or consider building your own custom library of type resolvers:
import { MyCorpTypes } from "@mycorp/standard-types" // class MyCorpTypes extends TypeRegistry {...}
const types = new MyCorpTypes();
const configurator = new Configurator({types})
Built-in Basic Type Names
stringnumberbooleanarraydatebufferset
More complex objects should be defined either via child schemas, or using dedicated Type Resolvers, and ideally will have a user-friendly value mapping from simple command-line or environment variable strings.
Arrays and Typed Arrays
Setting a schema field to type: "array" provides a basic array of values without typechecking; you must use
the $each validator to validate the provided values. Validators are allowed to coerce loosely typed
values to their preferred "valid" form.
For example, given:
this.schema.field('list', {type: 'array'});
this.schema.field('numbers', {type: 'array', validator: {$each: '$number'}});
providing --list 1 2 3 --numbers 1 2 3 to the command line will result in the array type resolver being called with
["1", "2", "3"] for both fields, but in the second case, the validator will convert each value to a number.
{
"list": ["1", "2", "3"],
"numbers": [1, 2, 3]
}
For this reason, Typed Arrays are also supported, using a bracket syntax:
this.schema.field('numbers', {type: '[number]'});
This will perform type resolution (via the number resolver, in this case) individually on each member of the input array,
and collect the resolved values into the output array.
Type Resolvers
Type Resolvers allow friendly user inputs to be intelligently converted to standardized configuration values;
as an example, here's a duration type that allows the user to specify --timeout 1m30s as an input,
but the configured value is defined as milliseconds:
types.defineType('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`);
});
schema.field('timeout', {type: 'duration'})
You can also create "dynamic types", that resolve depending on external state. Here's a crude "singleton manager":
class SingletonManager {
constructor(schema) {
this.schema = schema;
this.definitions = new Map();
}
register(ClassDefinition) {
if (!ClassDefinition?.constructor) {
throw new Error('Invalid class definition!')
}
const typeName = ClassDefinition.contructor.name;
this.definitions.set(typeName, {ClassDefinition});
// note that the assigned "value" is ignored entirely here, we just need a trigger.
this.schema.types.defineType(typeName, _ => {
let def = this.definitions.get(typeName);
if (!def.instance) {
def.instance = new def.ClassDefinition();
}
return def.instance;
});
}
}
const singletonManager = SingletonManager(schema);
singletonManager.register(SpecialClass);
// need at least one default assignment (any value) to trigger instantiation!
schema.field({special: {type: 'SpecialClass', default: true}})
A field without any assignments never calls its type resolver, so
add a "default" value to the field if you need your dynamic type to get
triggered without user input.
The call signature of a type resolver also passes the current configuration state, allowing for dynamic resolution based on completely unrelated field values:
this.schema.field('format', {type: 'string', validator: {$in: ['text', 'json']}, required: true}) // *required* to enforce one or the other will be set
this.schema.field('output', {type: 'OutputHandler', required: true, hidden: true, default: true}) // need a default to force evaluation
let outputHandler = undefined;
this.schema.types.defineType('OutputHandler', (_, config) => {
// ignore assigned value, we depend on --format json or --format text
if (config.format === 'text') {
outputHandler = new TextOutputHandler();
}
else if (config.format === 'json') {
outputHandler = new JsonOutputHandler();
}
return outputHandler; // returning undefined means we don't know yet, try again
});
In this case, the output field may try to resolve before format has
been resolved, so returning undefined indicates that it needs to retry.
Always expect that dynamic type resolvers may be called multiple times.
Be careful to differentiate falsey values from undefined!
Dynamic Typed List Resolvers
The default Typed Array per-value resolution behavior (described above) also works with custom Type Resolvers, but there is also a refinement available: you can define a Typed List Resolver, and it is then your responsibility to perform the array processing yourself. To define a Typed List Resolver, simply define an additional type resolver with the "bracketed" name. (The individual element type must also be defined.) The schema type resolver will first check if a Typed List Resolver exists before falling back to the default behavior.
For example, you could offer dynamic list creation, or perhaps ensure uniqueness:
class PluginManager {
plugins = new Map();
listPlugins() { return this.plugins.keys() }
activate(pluginName) {/*...*/}
}
//...
// resolve the individual plugin
types.defineType('plugin', async (value) => {
return await pluginManager.activate(value); // imagine this returns a plugin instance or throws if unknown...
})
// resolve a list of plugins
types.defineType('[plugin]', async (list) => {
if (!pluginManager.loaded) {
return undefined;
}
if (list === '*') {
list = pluginManager.listPlugins();
}
if (!Array.isArray(list)) {
list = [list];
}
return [...new Set(list)];
});
schema.field('plugins', {type: '[plugin]', default: '*'}) // by default, activate all plugins?
If any individual entry in the list cannot resolve yet, the list resolver should also return undefined.
Typed List Resolvers are only checked for [<type>] fields, not array fields.