Skip to main content

Children

You can create a child schema using schema.child(name, options). Child schemas share Types and Validators with their parent.

Like field names, the child name (converted to camelCase) becomes the name of a property in the output configuration object holding the fields defined in the child schema.

The most common use for a child schema is to encapsulate settings for application subsystems. The database interface provider should not care about settings related to web routing, so by grouping configurable properties by subsystem, you can enforce "separation of concerns".

Conditions

Schemas (or even individual fields) may define a "condition"—either a function or value that drives whether to evaluate the schema (or field) at all. Conditions take precedence even over defaults.

The most common use of a condition is to avoid defining any properties in a subsystem's schema if the subsystem is not used. For example, consider two alternative subsystems that each have their own configurable child schemas:

schema.field('format', {type: 'string', validator: {$in: ['text', 'json']}, required: true})
schema.field('output', {type: 'OutputHandler', required: true, hidden: true, default: true}) // need a default to force evaluation

schema.child('textOutput', {condition: (field, value, config) => (config.outputHandler === undefined? undefined : config.outputHandler instanceof TextOutputHandler)})
.field('pretty', {type: 'boolean', default: tty.isatty(1)})
schema.child('jsonOutput', {condition: (field, value, config) => (config.outputHandler === undefined? undefined : config.outputHandler instanceof JsonOutputHandler)})
.field('indent', {type: 'number', default: 2, validator: {$and: ['$positive', '$integer']}})

let outputHandler = undefined;
schema.types.defineType('OutputHandler', async (_, 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
});

const config = await new Configurator(schema).configure({
argv: ['--format', 'json', '--indent', 4]
})

// the configurables for each subsystem are defined in separate child schemas:
if (config.outputHandler instanceof TextOutputHandler) {
await config.outputHandler.init(config.textOutput);
}
else if (config.outputHandler instanceof JsonOutputHandler) {
await config.outputHandler.init(config.jsonOutput);
}

The defined conditions ensure that the generated configuration will only contain configurable assignments for a given subsystem if the relevant handler is actually instantiated.

(If this seems like a lot of boilerplate... you're right! Once your application is at a level where this type of complexity is necessary, you are now entering the realm where the ModuleManager extension to Configurator starts to make sense. ModuleManager handles all this automatically by associating each application subsystem (aka "module") with a dedicated type for dependency injection, and a self-describing conditional child schema for its own private configurables.)