Transformers
Schemas use transformers to convert the canonical normalized input format into a valid output format.
In many cases, no transformation is needed - the normalized form is the output form.
This is the case for simple schemas, like number, string, or boolean.
Transformers can be used for anything from data hydration to completely changing the data shape.
Here's an example of a schema that transforms a raw object input into a class instance:
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
class Vector {
x = 0;
y = 0;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
add(vector) {
this.x += vector.x;
this.y += vector.y;
return this;
}
}
export const vs = resolver.compile(
new Schema('object')
.property('x', new Schema('number'))
.property('y', new Schema('number'))
.transformer(v => new Vector(v?.x, v?.y))
.validator({$instanceof: Vector})
);
const vec1 = await vs.process({x: 10, y: 10});
const vec2 = await vs.process({x: 20, y: 20});
vec1.add(vec2);
console.log(vec1); // Vector { x: 30, y: 30 }
const v = { x: 10, y: 10 }
Transformations and Opaque Values
In the above schema (and class), notice that the x and y properties
could be legally assigned before or after transformation to a Vector; it works either way.
By default, containers are created empty (by normalizing Schema.EMPTY) and
then this empty container is transformed - before any children are visited.
Then, the children each go through their own processing, and are added
incrementally to the container.
So this example works only because it's ok to create an empty Vector and then
assign its properties incrementally. This is not always the case!
For example, here's a simple Color class that needs to be created from its constructor,
which means that all the parameters need to be available.
Let's see what happens if we don't take any precautions about incremental assignments:
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
class Color {
value = '#000000';
constructor(red, green, blue) {
const f = n => (n & 255).toString(16).padStart(2, '0');
this.value = '#' + f(red) + f(green) + f(blue);
}
}
export const colorSchema = new Schema('object')
// BROKEN until you uncomment the next line!
//.opaque()
.property('red', new Schema('number').validator('$integer'))
.property('green', new Schema('number').validator('$integer'))
.property('blue', new Schema('number').validator('$integer'))
.transformer(v => new Color(v.red, v.green, v.blue))
.validator({$instanceof: Color})
const cs1 = resolver.compile(colorSchema);
const yellowInput = {red: 255, green: 255, blue: 0}
const yellowOutput = await cs1.process(yellowInput);
// oops!
console.log(yellowOutput);
As it stands, this is very broken. The constructor was called with undefined values, and undefined & 255
evaluates to 0. Then, the new Color instance was overwritten with
red, green, and blue properties. Not what we want at all!
The opaque option fixes this. It indicates
that this schema produces an opaque value, and it does not support incremental assignments
(or schema inspection) of the post-transform value.
Note that an alternate approach would be to return undefined from the
transformer until all three values are set:
(v => {
if (v.red !== undefined && v.green !== undefined && v.blue !== undefined) {
return new Color(v.red, v.green, v.blue);
}
return undefined;
})
An undefined return indicates that the handler should be retried in a subsequent traversal pass.
An expanded version of this example can be found in the source repo.