Creating Variants
Variant aims to give the user complete control over how their objects are created. A variant's constructor may perform side effects, rely on asynchronous information, or generate objects of any kind.
A call to variant()
must be accompanied by a template that expresses the possibilities of the variant. This template may be given as an object or an array.
Object Templates​
ts
import {variant ,fields ,VariantOf } from 'variant';export constAnimal =variant ({cat :fields <{name : string,furnitureDamaged : number}>(),dog :fields <{name : string,favoriteBall ?: string}>(),snake : (name : string,pattern : string = 'striped') => ({name ,pattern }),});export typeAnimal =VariantOf <typeofAnimal >;ts
import {variant ,fields ,VariantOf } from 'variant';export constAnimal =variant ({cat :fields <{name : string,furnitureDamaged : number}>(),dog :fields <{name : string,favoriteBall ?: string}>(),snake : (name : string,pattern : string = 'striped') => ({name ,pattern }),});export typeAnimal =VariantOf <typeofAnimal >;When given an object template,
variant()
will treat each property as one of the variations. The property's label will become the type and the value will be used to create a factory function.Array Templates​
ts
constSuit =variant (['Spades', 'Hearts', 'Clubs', 'Diamonds']);typeSuit =VariantOf <typeofSuit >;ts
constSuit =variant (['Spades', 'Hearts', 'Clubs', 'Diamonds']);typeSuit =VariantOf <typeofSuit >;In the array template, each element must be a string literal (which will become the empty variant
{type: T extends string})
, or a call tovariation()
, like so:ts
constAction =variant (['RefreshAnimals','StartGame',variation ('RescueAnimal',payload <Animal >()),])typeAction =VariantOf <typeofAction >;ts
constAction =variant (['RefreshAnimals','StartGame',variation ('RescueAnimal',payload <Animal >()),])typeAction =VariantOf <typeofAction >;
The object notation is recommended most of the time. The array notation is more convenient when most members of the variant are simple types with no data, but the object notation is a little clearer to read and will forward documentation on the template down to the final constructors and interfaces.
Defining Bodies​
The body of a variant—the shape of the data carried by some particular form—is defined by a function. More specifically, it is defined by that function's return value. In our snake example from earlier, we returned an object containing the properties name
and pattern
, which we sourced from our inputs.
When a variant is defined, it wraps the body of the function it receives into a new function. That new variant creator has the same inputs, and almost the same output (it merges in the type
property).
Most of the time we will use helper functions like payload()
or fields()
, not because they increase our capabilities, but because they streamline how we think about and manage our domain.
The beautiful thing about using a function as the definition of a variant is that it is both the simplest option and the nuclear option. Functions are capable of
- zero, one, multiple, optional/default, and variadic parameters.
- arbitrary processing logic like validation.
- asynchronous calls.
- side effects like logging.
- referencing closures.
Adding to that power, the objects they return may contain internal state, methods, and property accessors. In the few cases that isn't sufficient variants can also be generated from full classes with construct()
.
For empty bodies​
To express a case that has no data, use nil
, or {}
. Pick whichever speaks to you.
ts
constAction =variant ({RefreshAnimals : {},StartGame :nil ,RescueAnimal :payload <Animal >(),});
ts
constAction =variant ({RefreshAnimals : {},StartGame :nil ,RescueAnimal :payload <Animal >(),});
Some syntax highlighters will interpret {}
as a block and change the color of the key. If this bothers you, use nil
For one piece of data​
Use payload<T>()
, or use a simple function to retain the name.
ts
import {variant ,VariantOf ,payload } from 'variant';ÂconstSomething =variant ({first :payload <string>(),second : (label : string) => ({label }),})typeSomething =VariantOf <typeofSomething >;
ts
import {variant ,VariantOf ,payload } from 'variant';ÂconstSomething =variant ({first :payload <string>(),second : (label : string) => ({label }),})typeSomething =VariantOf <typeofSomething >;
For one or more named fields​
The fields<T>()
function from earlier allows us to do this.
ts
import {variant ,fields ,VariantOf } from 'variant';export constAnimal =variant ({cat :fields <{name : string,furnitureDamaged : number}>(),dog :fields <{name : string,favoriteBall ?: string}>(),snake : (name : string,pattern : string = 'striped') => ({name ,pattern }),});export typeAnimal =VariantOf <typeofAnimal >;
ts
import {variant ,fields ,VariantOf } from 'variant';export constAnimal =variant ({cat :fields <{name : string,furnitureDamaged : number}>(),dog :fields <{name : string,favoriteBall ?: string}>(),snake : (name : string,pattern : string = 'striped') => ({name ,pattern }),});export typeAnimal =VariantOf <typeofAnimal >;
For classes​
Use construct()
to work with classes. This function can accept anonymous class definitions, or work with previously defined classes in-scope to support instanceof
checks down the road. Let's make a class-based dog with some internal state
ts
classDog {constructor(privatebarkVolume : number) { }Âpublicbark () {// can access class members.constmsg = this.barkVolume > 5 ? 'BARK' : 'bark';console .log (msg );}}
ts
classDog {constructor(privatebarkVolume : number) { }Âpublicbark () {// can access class members.constmsg = this.barkVolume > 5 ? 'BARK' : 'bark';console .log (msg );}}
This class can be incorporated in our new version of Animal
.
ts
constClassyAnimal =variant ({dog :construct (Dog ),cat :construct (class {publicfurnitureDamaged = 0;}),snake :construct (class {constructor(privatecolor : string,privateisStriped : boolean = false,) { }Âgetskin () {return `${this.isStriped && 'striped '}${this.color }`;}})});typeClassyAnimal =VariantOf <typeofClassyAnimal >;Âconstdog =ClassyAnimal .dog (4);constisDog =dog instanceofDog ; // true!
ts
constClassyAnimal =variant ({dog :construct (Dog ),cat :construct (class {publicfurnitureDamaged = 0;}),snake :construct (class {constructor(privatecolor : string,privateisStriped : boolean = false,) { }Âgetskin () {return `${this.isStriped && 'striped '}${this.color }`;}})});typeClassyAnimal =VariantOf <typeofClassyAnimal >;Âconstdog =ClassyAnimal .dog (4);constisDog =dog instanceofDog ; // true!
Complications​
If only real-world use always resembled ideal cases. Here are some ways to complicate the setup for various purposes.
- Computed keys allow for the type literals to be based on a pre-existing enum or const object.
- Top-level constructors allow for fp-like tags (though be warned, they are still polymorphic variants)
- Labels that don't match the type literals they generate can be useful for scoped types or supporting legacy protocols.
Computed Keys​
In the earlier examples we defined a variant template as an object literal. The keys of the literal are what will become the types of each variant. However, the library is perfectly happy to accept computed keys including constants objects or string literals.
ts
export constAniType = {dog : 'dog',cat : 'cat',snake : 'snake',} asconst ;Âexport constAnimal =variant ({[AniType .dog ]:fields <{name : string,favoriteBall ?: string}>(),[AniType .cat ]:fields <{name : string,furnitureDamaged : number}>(),[AniType .snake ]: (name : string,pattern : string = 'striped') => ({name ,pattern }),})
ts
export constAniType = {dog : 'dog',cat : 'cat',snake : 'snake',} asconst ;Âexport constAnimal =variant ({[AniType .dog ]:fields <{name : string,favoriteBall ?: string}>(),[AniType .cat ]:fields <{name : string,furnitureDamaged : number}>(),[AniType .snake ]: (name : string,pattern : string = 'striped') => ({name ,pattern }),})
It's also possible to use a string enum to similar effect.
ts
export enumAniType {dog = 'dog',cat = 'cat',snake = 'snake',}
ts
export enumAniType {dog = 'dog',cat = 'cat',snake = 'snake',}
Though my recommendation for AniType
's definition would be catalog.
ts
export constAniType =catalog (['dog', 'cat', 'snake']);
ts
export constAniType =catalog (['dog', 'cat', 'snake']);
Top-level Constructors​
I find great utility in consolidating the relevant cases, but I'm sympathetic to the desire to have these tag constructors as top-level functions in the scope, rather than being under Animal
. This works just fine. The object Animal
is very intentionally just a loose collection of the constructors, destructure or regroup it as you wish.
ts
export const {cat ,dog ,snake } =Animal ;Âconstgarfield =cat ({name : 'Garfield',furnitureDamaged : 12});constechidna =snake ('Echidna', 'speckled');
ts
export const {cat ,dog ,snake } =Animal ;Âconstgarfield =cat ({name : 'Garfield',furnitureDamaged : 12});constechidna =snake ('Echidna', 'speckled');
The instance type for cat
can be retrieved as ReturnType<typeof cat>
. However. there's nothing wrong with exporing the constructors at the top level and also exporting the Animal type. Doing so means consumers still have the ability to type something as Animal<'cat'>
, which will save you some typing and duplication of effort.
Under the hood, variant()
creates variant constructors by calling the function variation()
. This function can be used directly to create variant constructors at the top level.
ts
import {variation } from 'variant';Âconstsnake =variation ('snake', (name : string,pattern : string = 'striped') => ({name ,pattern }));constechidna =snake ('Echidna', 'speckled');
ts
import {variation } from 'variant';Âconstsnake =variation ('snake', (name : string,pattern : string = 'striped') => ({name ,pattern }));constechidna =snake ('Echidna', 'speckled');
Differing key labels and names​
In many cases, the label used when referring to a variant is exactly what is used in the underlying type
field. However, this is not always desirable.
- Sometimes coding conventions will dictate
camelCase
orPascalCase
names while database/network conventions will demandALL_CAPS
. - The
UPPER_SNAKE_CASE
format has historically been the most common naming scheme for constant values. Perhaps you'll need to support them to support existing code or data models. - In larger codebases, it may be necessary to start introducing scopes to avoid name collisions. These might look something like
@player/update
orAUDIT::END_RECORDING
. These strings contain special characters and so are not valid property names, but may be required by your code. Ideally, the variant creators would manage that complexity.
Using variation()
resolves these concerns. The first parameter, the string, will be the actual underlying type (both at runtime and compile time). The second parameter is the function that will handle the rest of the body.
ts
import {variant ,variation ,fields } from 'variant';ÂconstAction =variant ({DoSomething :variation ('DO_SOMETHING'),LoadThing :variation ('LOAD_THING',fields <{thingId : number}>()),RefreshPage :variation ('REFRESH_PAGE'),})ÂconstdoAction =Action .DoSomething ();doAction .type
ts
import {variant ,variation ,fields } from 'variant';ÂconstAction =variant ({DoSomething :variation ('DO_SOMETHING'),LoadThing :variation ('LOAD_THING',fields <{thingId : number}>()),RefreshPage :variation ('REFRESH_PAGE'),})ÂconstdoAction =Action .DoSomething ();doAction .type
variation()
can also be used individually, similarly to createAction
.