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;
}
}
const vs = await 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})
.serializer(v => ({x: v.x, y: v.y}))
);
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 }
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);
}
}
const colorSchema = new Schema('object')
.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 = await resolver.compile(colorSchema);
const yellow1 = await cs1.process({red: 255, green: 255, blue: 0});
// oops!
console.log(yellow1); // -> Color { value: '#000000', red: 255, green: 255, blue: 0 }
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.
// clone the colorSchema to break the compilation cache, and set the opaque option
const cs2 = await resolver.compile(new Schema(colorSchema).opaque());
const yellow2 = await cs2.process({red: 255, green: 255, blue: 0});
console.log(yellow2); // -> Color { value: '#ffff00' }
An expanded version of this example can be found in the source repo