Normalizers
Normalizers are used to convert input data into a canonical form that can be more easily processed by the rest of the schema.
This is useful for converting messy (or "friendly", originating from a user) inputs into a standardized form.
Example
For example, here's a duration schema that outputs milliseconds, but also accepts
formatted input values like "1h" for one hour:
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
function 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`);
}
const durationSchema = new Schema().normalizer(duration);
resolver.registerSchema('duration', durationSchema);
// Use it in a schema
const schema = await resolver.compile(
new Schema('object').property('timeout', new Schema('duration'))
);
console.log( await schema.process( { timeout: 1000 }) ); // -> { timeout: 1000 }
console.log( await schema.process( { timeout: '5m' }) ); // -> { timeout: 300000 }
Pipeline Ordering
Notice that this duration schema is built on an empty new Schema(), and not a number.
This has the downside that it provides no validation, schema.validate('xyz') will happily
return 'xyz' with no error. Since this schema is really just providing some friendly
parsing, but is otherwise a straightforward numeric value, it would be nice to leverage
the prebuilt number schema.
Let's see what happens if we were to extend number:
const d1s = await resolver.compile( new Schema('number').normalizer(duration) );
await d1s.process('5m'); // oops, throws NormalizeError!
Our duration normalizer is being appended to the existing pipeline provided
by the number schema. What we want is to have it at the very front of the pipeline,
before the number schema normalizer runs.
We have some choices:
Approach 1: Build our own number-like schema:
The number schema is actually quite simple, and is built on top of prebuilt value processors.
We can even extend it with other validation requirements, like ensuring it's $positive:
const d2s = await resolver.compile(
new Schema()
.normalizer(duration)
.normalizer('$number')
.validator('$is-number')
.validator('$positive')
);
Disadvantage: the actual number schema also sets some options and metadata that our version lacks,
used primarily for allowing wrapper code to introspect the schema types (e.g. for building command line
parsers) Not a big deal, but it means that we need to either accept that our schema is just "number-ish",
or we need to keep an eye on the backing library implementation and ensure we stay fully in sync.
Approach 2: Preresolve number and push our processor to the front of the normalizer pipeline:
A schema constructed with new Schema('number') does not actually have any normalizers
defined until after it is compiled.
The compilation step that looks up named base schema references is implemented by
SchemaResolver.resolve(). This method
returns a new schema with the base schema hierarchy "flattened".
Then, instead of calling .normalizer() which appends normalizers to the pipeline, we can use
.normalizers(), which allows an explicit policy parameter
to be provided for how the provided normalizer(s) are treated. We'll use PREPEND:
import { Schema, SchemaResolver, SchemaPolicy } from '@versionzero/schema';
const d3s = await resolver.compile(
resolver.resolve(new Schema('number'))
.normalizers(duration, SchemaPolicy.PREPEND)
.validator('$positive')
);
All handlers support this extended form.
Notes
- For best performance, avoid using async processors in the normalizer handler.
- Normalizers should be deterministic and not depend on the overall target value context,
because
Schemadefaults and values are passed through the normalizer handler at compilation time. - Normalizers should generally always accept transformed values as a legal input. For example, the prebuilt
dateschema that emitsDateobjects accepts both ISO strings andDateinstances for normalization. - If the output from a normalizer is not serializable to JSON, make sure to implement a serializer handler.