Advanced Creation
We may wish to have more control or convenience in our variant creation. This library provides a series of inline and top-level helper functions to assist with these efforts. The inline functions are called as an element of variantModule
and can be chained. The top-level helpers require special handling.
Inline helpers:
constrained
— every case must handle these inputs and provide these outputs.patterned
— every case must handle exactly these inputs and no more, while providing these outputs.augmented
— every case gets some dynamic properties patched into its output.
The top-level helpers:
typedVariant
— the cases follow a pre-existing type (necessary for recursive variants).genericVariant
— some cases may use generic parameters.scopedVariant
— the variant is scoped, meaning the types you provide are prefixed with${scope}/
.
Constrained Variants
We can use the constrained
to create a module where every variant must support the inputs and output types of an example function we pass in. In my hypothetical game the player can change their hairstyle, but only if the currently have enough hair to fit that style. Someone who is bald cannot restyle their hair into a ponytail, but it is perfectly okay for a person with a ponytail to cut their hair to fit a shorter style. To help represent that I'm going to give each hairstyle an optional minimum and maximum length. When it comes time to check if a particular restyling is valid I can simply check if player.hair.length >= (targetStyle.min ?? 0)
.
ts
/*** Hairstyles with listed min and max length requirements.* - HairLength is an enum*/export const Hairstyle = variantModule(constrained(just<{min?: HairLength, max?: HairLength}>({}),{Afro: just({min: HairLength.Short}),Bald: just({max: HairLength.Bald}),Bun: just({min: HairLength.Medium, max: HairLength.Long}),CrewCut: just({min: HairLength.Buzzed, max: HairLength.Short}),Dreadlocks: just({min: HairLength.Short}),Mohawk: just({min: HairLength.Buzzed, max: HairLength.Long}),Ponytail: just({min: HairLength.Medium}),Shaved: just({min: HairLength.Bald, max: HairLength.Buzzed}),Undercut: just({min: HairLength.Buzzed}),Waves: just({min: HairLength.Medium}),},));
ts
/*** Hairstyles with listed min and max length requirements.* - HairLength is an enum*/export const Hairstyle = variantModule(constrained(just<{min?: HairLength, max?: HairLength}>({}),{Afro: just({min: HairLength.Short}),Bald: just({max: HairLength.Bald}),Bun: just({min: HairLength.Medium, max: HairLength.Long}),CrewCut: just({min: HairLength.Buzzed, max: HairLength.Short}),Dreadlocks: just({min: HairLength.Short}),Mohawk: just({min: HairLength.Buzzed, max: HairLength.Long}),Ponytail: just({min: HairLength.Medium}),Shaved: just({min: HairLength.Bald, max: HairLength.Buzzed}),Undercut: just({min: HairLength.Buzzed}),Waves: just({min: HairLength.Medium}),},));
The first parameter was our example function, which evaluates to () => {min?: HairLength, max?: HairLength}
. Now when we implement our variants we will have autocomplete for our return values. If our example function had an input, every case constructor would need to accept that type of variable as its first argument.
When using constrainedVariant, optional properties of the return type will be available on the union. It will be possible to inspect min
and max
without needing to understand the
Note that these variant bodies can still require extra information. Any such variants can simply require multiple parameters as long as the first parameters line up with the example. If you need to ensure that every case constructor takes no parameters, this isn't the best choice, you want patternedVariant()
.
Patterned Variants
patterned
is the same as as constrained
, except your case constructors are not allowed any extraneous properties—they must exactly match the example function.
Our previous code will work again, as is.
ts
/*** Hairstyles with listed min and max length requirements.* - HairLength is an enum*/export const Hairstyle = variantModule(patterned(just<{min?: HairLength, max?: HairLength}>({}),{Afro: just({min: HairLength.Short}),Bald: just({max: HairLength.Bald}),Bun: just({min: HairLength.Medium, max: HairLength.Long}),CrewCut: just({min: HairLength.Buzzed, max: HairLength.Short}),Dreadlocks: just({min: HairLength.Short}),Mohawk: just({min: HairLength.Buzzed, max: HairLength.Long}),Ponytail: just({min: HairLength.Medium}),Shaved: just({min: HairLength.Bald, max: HairLength.Buzzed}),Undercut: just({min: HairLength.Buzzed}),Waves: just({min: HairLength.Medium}),},));
ts
/*** Hairstyles with listed min and max length requirements.* - HairLength is an enum*/export const Hairstyle = variantModule(patterned(just<{min?: HairLength, max?: HairLength}>({}),{Afro: just({min: HairLength.Short}),Bald: just({max: HairLength.Bald}),Bun: just({min: HairLength.Medium, max: HairLength.Long}),CrewCut: just({min: HairLength.Buzzed, max: HairLength.Short}),Dreadlocks: just({min: HairLength.Short}),Mohawk: just({min: HairLength.Buzzed, max: HairLength.Long}),Ponytail: just({min: HairLength.Medium}),Shaved: just({min: HairLength.Bald, max: HairLength.Buzzed}),Undercut: just({min: HairLength.Buzzed}),Waves: just({min: HairLength.Medium}),},));
The difference is that now I cannot add any extra inputs to the functions. This is an excellent feature if we would like to automatically create an instance of a variant without knowing which constructor we're using. It is now safe for me to operate under the assumption I will never have to pass properties to a hairstyle constructor, enabling random generation like so:
ts
const styleList = Object.values(Hairstyle);const styleCreator = styleList[Math.floor(Math.random() * styleList.length)];const style = styleCreator();
ts
const styleList = Object.values(Hairstyle);const styleCreator = styleList[Math.floor(Math.random() * styleList.length)];const style = styleCreator();
Augmented Variants
I found myself wanting to add a {timestamp: number}
property to one of my variants. It was representing time-series data and I didn't want to pass in a timestmap each and every time, nor did I want to ensure one existed in every constructor, so I wrote augmented
to address this. Like the functions so far, augmented
is used inside of a variantModule
call.
ts
export const Protocol = variantModule(augmented(() => ({timestamp: Date.now()}),{ServerInit: {},ClientConnected: payload<ClientInfo>(),ClientDisconnected: fields<{clientId: string, message: string}>(),TextMessage: fields<{id: string, text: string, senderId: string}>(),ReadReceipt: fields<{id: string, textId: string, readerId: string}>(),}));
ts
export const Protocol = variantModule(augmented(() => ({timestamp: Date.now()}),{ServerInit: {},ClientConnected: payload<ClientInfo>(),ClientDisconnected: fields<{clientId: string, message: string}>(),TextMessage: fields<{id: string, text: string, senderId: string}>(),ReadReceipt: fields<{id: string, textId: string, readerId: string}>(),}));
Recursive Variants
Recursive variants are a wonderful pattern for expressing and evaluating tree and list-like data. The traditional example involves a binary tree and we'll implement a proper one in the next section on generic variants.. In the meantime, let's do a binary tree of Animal
s. An animal tree may not have many real world applications but please bear with me.
typedVariant<T>()
So far we've been letting the type flow from the value. However, this makes recursive variants impossible. Attempting to reference AnimalNode
in the definition for AnimalNode
causes an error in the time-loop (and tsc
).
So we've got flip our approach. We're going to make a type and then create the variant module, the value, based on that type.
typescript
type AnimalTree =| Variant<'Leaf', {animal: Animal}>| Variant<'Branch', {left?: AnimalTree, right?: AnimalTree, label?: string}>;const AnimalTree = typedVariant<AnimalTree>({Leaf: pass,Branch: pass,});const tree = AnimalTree.Branch({label: 'Animal Kingdom',left: AnimalTree.Leaf({animal: Animal.snake({name: 'Steve'})),right: AnimalTree.Branch({label: 'Mammals',left: AnimalTree.Leaf({animal: Animal.dog({name: 'Cerberus'})}),right: AnimalTree.Leaf({animal: Animal.cat({name: 'Sikandar'})}),})})
typescript
type AnimalTree =| Variant<'Leaf', {animal: Animal}>| Variant<'Branch', {left?: AnimalTree, right?: AnimalTree, label?: string}>;const AnimalTree = typedVariant<AnimalTree>({Leaf: pass,Branch: pass,});const tree = AnimalTree.Branch({label: 'Animal Kingdom',left: AnimalTree.Leaf({animal: Animal.snake({name: 'Steve'})),right: AnimalTree.Branch({label: 'Mammals',left: AnimalTree.Leaf({animal: Animal.dog({name: 'Cerberus'})}),right: AnimalTree.Leaf({animal: Animal.cat({name: 'Sikandar'})}),})})
In this example we created a recursive type, a binary tree of Animal
s. We then created the implementation of that type as a variant module by calling typedVariant<T>()
.
pass
pass
achieves the most common use case—create a variant that accepts an object of a given type and returns that object plus the type: ...
property. The user is welcome to write the implementation themselves, but most often pass
is sufficient.
custom implementation
typedVariant<T>()
uses the type T
to restrict the object you offer as the implementation, meaning you can safely destructure the variant's input in its own implementation.
typescript
const AnimalTree = typedVariant<AnimalTree>({Leaf: ({animal}) => {console.log('creating leaf node with animal', animal);return {animal};},Branch: pass,});
typescript
const AnimalTree = typedVariant<AnimalTree>({Leaf: ({animal}) => {console.log('creating leaf node with animal', animal);return {animal};},Branch: pass,});
Generic Variants
Generic variants will enable us to create a more flexible binary tree, among many other structures.
To use the genericVariant()
, pass in a function that returns a variant module. genericVariant
will provide your function with a set of sigils that you can use as placeholder types. These will be replaced by true generics in the resulting type! In the example, I use T, but this is actually a property of a larger object being destructured which contains the full alphabet {A: _, B: _, C: _, ..., Z: _}
. Use whichever letter best fits your use case.
- Variant 2.1+
- Variant 2.0
Before we hit the recursive and generic nature of trees, let's make a pit-stop in the classic and well-loved Option type.
typescript
const [Option, __Option] = genericVariant(({T}) => ({Some: payload(T),None: {},}));type Option<T, TType extends GTypeNames<typeof __Option> = undefined>= GVariantOf<typeof __Option, TType, {T: T}>;const num = Option.Some(4);const name = Option.Some('Steve');
typescript
const [Option, __Option] = genericVariant(({T}) => ({Some: payload(T),None: {},}));type Option<T, TType extends GTypeNames<typeof __Option> = undefined>= GVariantOf<typeof __Option, TType, {T: T}>;const num = Option.Some(4);const name = Option.Some('Steve');
In this example, Option
is the actual module. __Option
is a local version with the original tokens intact which is only used for type calculations. For various reasons, it is difficult for TypeScript to re-interpret the manifestly generic Option
object.
We can also write functions that operate on the generic type.
typescript
// Option<T> -> T | undefinedfunction extract<T>(opt: Option<T>) {return match(opt, {Some: unpack,None: just(undefined),});}// resultsexpect(num.payload).toBe(4);expect(extract(num)).toBe(4);expect(extract(name)).toBe('Steve');expect(extract(Option.None())).toBeUndefined();
typescript
// Option<T> -> T | undefinedfunction extract<T>(opt: Option<T>) {return match(opt, {Some: unpack,None: just(undefined),});}// resultsexpect(num.payload).toBe(4);expect(extract(num)).toBe(4);expect(extract(name)).toBe('Steve');expect(extract(Option.None())).toBeUndefined();
Tree
Trees are a bit more complex than the option type because they are recursive by nature. Much like in the recursive variants example, we'll need to define a type first.
typescript
const [Tree, __Tree] = genericVariant(({T}) => {type Tree<T> =| Variant<'Branch', {payload: T, left: Tree<T>, right: Tree<T>}>| Variant<'Leaf', {payload: T}>;return {Branch: fields<{left: Tree<typeof T>, right: Tree<typeof T>, payload: typeof T}>(),Leaf: payload(T),}});type Tree<T, TType extends GTypeNames<typeof __Tree> = undefined>= GVariantOf<typeof __Tree, TType, {T: T}>;// in useconst binTree = Tree.Branch({payload: 1,left: Tree.Branch({payload: 2,left: Tree.Leaf(4),right: Tree.Leaf(5),}),right: Tree.Leaf(3),})
typescript
const [Tree, __Tree] = genericVariant(({T}) => {type Tree<T> =| Variant<'Branch', {payload: T, left: Tree<T>, right: Tree<T>}>| Variant<'Leaf', {payload: T}>;return {Branch: fields<{left: Tree<typeof T>, right: Tree<typeof T>, payload: typeof T}>(),Leaf: payload(T),}});type Tree<T, TType extends GTypeNames<typeof __Tree> = undefined>= GVariantOf<typeof __Tree, TType, {T: T}>;// in useconst binTree = Tree.Branch({payload: 1,left: Tree.Branch({payload: 2,left: Tree.Leaf(4),right: Tree.Leaf(5),}),right: Tree.Leaf(3),})
With this definition of Tree<T>
I'm able to write the kind of recursive depth-first traversal function many of us are familiar with.
typescript
function depthFirst<T>(node: Tree<T>): T[] {return match(node, {Leaf: ({payload}) => [payload],Branch: ({payload, left, right}) => {return [payload, ...depthFirst(left), ...depthFirst(right)];}})}const [d1, d2, d3, d4, d5] = depthFirst(binTree);expect(d1).toBe(1);expect(d2).toBe(2);expect(d3).toBe(4);expect(d4).toBe(5);expect(d5).toBe(3);
typescript
function depthFirst<T>(node: Tree<T>): T[] {return match(node, {Leaf: ({payload}) => [payload],Branch: ({payload, left, right}) => {return [payload, ...depthFirst(left), ...depthFirst(right)];}})}const [d1, d2, d3, d4, d5] = depthFirst(binTree);expect(d1).toBe(1);expect(d2).toBe(2);expect(d3).toBe(4);expect(d4).toBe(5);expect(d5).toBe(3);
typescript
type Tree<T> =| Variant<'Leaf', {payload: T}>| Variant<'Branch', {left: Tree<T>, right: Tree<T>}>;const Tree = genericVariant(({T}) => ({Branch: fields<{left: Tree<typeof T>, right: Tree<typeof T>}>(),Leaf: payload(T),}));const leaf = Tree.Leaf(5);const numTree = Tree.Branch({left: Tree.Leaf(4),right: Tree.Leaf(66),});const strTree = Tree.Branch({left: Tree.Leaf('hello'),right: Tree.Branch({left: Tree.Leaf('world'),right: Tree.Leaf('people'),}),})
typescript
type Tree<T> =| Variant<'Leaf', {payload: T}>| Variant<'Branch', {left: Tree<T>, right: Tree<T>}>;const Tree = genericVariant(({T}) => ({Branch: fields<{left: Tree<typeof T>, right: Tree<typeof T>}>(),Leaf: payload(T),}));const leaf = Tree.Leaf(5);const numTree = Tree.Branch({left: Tree.Leaf(4),right: Tree.Leaf(66),});const strTree = Tree.Branch({left: Tree.Leaf('hello'),right: Tree.Branch({left: Tree.Leaf('world'),right: Tree.Leaf('people'),}),})
Scoped Variants
🔮 denotes preview content. These are features that are available, but not well-documented and may be modified in the near future as they see better integration.
As of TypeScript 4.1 it is possible to create template literal types, enabling type-safe scoped variants. This feature will be available in variant@2.2
, and may be tried now through npm i variant@test
.
We may eventually run into name conflicts with our type
properties, for example when multiple collections of actions are combined into one large Action
type. We can address this by using scopedVariant
to prefix our types with some distinguishing name.
ts
export const ScopedAnimal = scopedVariant('animal', {dog: fields<{name: string, favoriteBall?: string}>(),cat: fields<{name: string, furnitureDamaged: number}>(),snake: (name: string, pattern = 'striped') => ({name, pattern}),bird: {},});
ts
export const ScopedAnimal = scopedVariant('animal', {dog: fields<{name: string, favoriteBall?: string}>(),cat: fields<{name: string, furnitureDamaged: number}>(),snake: (name: string, pattern = 'striped') => ({name, pattern}),bird: {},});
Now the type
of ScopedAnimal.dog()
is animal/dog
instead of just dog
. Unfortunately, this makes matching a little bit more tedious:
ts
const getRating = (a: ScopedAnimal) => match(a, {"animal/dog": constant(10),default: constant(6),});
ts
const getRating = (a: ScopedAnimal) => match(a, {"animal/dog": constant(10),default: constant(6),});
But that is quickly resolved through the use of descope()
ts
return match(descope(a), {bird: constant(6),cat: constant(8),dog: constant(10),snake: constant(5),})
ts
return match(descope(a), {bird: constant(6),cat: constant(8),dog: constant(10),snake: constant(5),})