Introduction
Variant is a language feature disguised as a library.
Variant aims to bring the experience of variant types to TypeScript. Variant types, a.k.a. discriminated unions in the TypeScript world, are an excellent tool for describing and handling flexible domain models and tiny DSLs. However, because "TypeScript instead builds on JavaScript patterns as they exist today" using them as-is can result in tedious and fragile code. This project addresses that by providing well-typed, fluent, and expressive tools to safely do away with the boilerplate.
The term Domain has many meanings but here we use domain to mean your set of concerns. If you are making a game, you might care about the player state, potential enemies, or items and inventory. If you are writing a budget tracker, your concerns might include income sources and recurring vs. one-time expenses.
🧠Click here to jump straight to the API Reference
Quick Start​
Variant doesn't have any dependencies and doesn't need any setup. Simply run npm install
.
bash
npm install --save variant
bash
npm install --save variant
Let's use variant
to describe a simple domain — Animals. Or if you'd like a redux example...
For this application, we care about dogs, cats, and snakes. These will be the various pets our player can have. We have different concerns for each animal, so we'll want to define them with distinct fields. The fields
function below is shorthand to help do this. We'll see more of how it works in the first section of the User Guide.
typescript
import {variant, variantModule, VariantOf, fields, TypeNames} from 'variant';export const Animal = variantModule({dog: fields<{name: string, favoriteBall?: string}>(),cat: fields<{name: string, furnitureDamaged: number}>(),snake: (name: string, pattern = 'striped') => ({name, pattern}),});// optional, but very helpful.export type Animal<T extends TypeNames<typeof Animal> = undefined>= VariantOf<typeof Animal, T>;
typescript
import {variant, variantModule, VariantOf, fields, TypeNames} from 'variant';export const Animal = variantModule({dog: fields<{name: string, favoriteBall?: string}>(),cat: fields<{name: string, furnitureDamaged: number}>(),snake: (name: string, pattern = 'striped') => ({name, pattern}),});// optional, but very helpful.export type Animal<T extends TypeNames<typeof Animal> = undefined>= VariantOf<typeof Animal, T>;
We can now import and use the Animal
object, which simply collects the tag constructors we care about in one place. To create a new dog, for example, call Animal.dog({name: 'Guava'})
. When we imported the Animal
object we also imported the Animal
type since we defined these with the same name. This single import will allows us to:
- Create a new animal
Animal.snake('Steve')
— value:{ type: 'snake', name: 'Steve', pattern: 'striped' }
- Annotate the type for a single animal.
Animal<'snake'>
— type:{ type: 'snake', name: string, pattern: string }
- Annotate the union of all animals.
Animal
— type:Animal<'dog'> | Animal<'cat'> | Animal<'snake'>
typescript
import {Animal} from '...';const snek = Animal.snake('steve');const describeSnake = (snake: Animal<'snake'>) => {...}const describeAnimal = (animal: Animal) => {...}
typescript
import {Animal} from '...';const snek = Animal.snake('steve');const describeSnake = (snake: Animal<'snake'>) => {...}const describeAnimal = (animal: Animal) => {...}
With these building blocks we're ready to write some elegant code. Let's implement the describeAnimal
function with the match
utility.
Match​
Match is a great tool to process a variant of unknown type. The function will accept a variant object (animal) and a handler object. Think of each entry of the handler like a branch that might execute. We'll have to describe how to deal with every option to be safe.
In this case, let's describe how each animal is relaxing in the bedroom.
typescript
import {match} from 'variant';const describeAnimal = (animal: Animal) => match(animal, {cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,});
typescript
import {match} from 'variant';const describeAnimal = (animal: Animal) => match(animal, {cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,});
If any of this syntax looks unfamiliar, take a look at ES6 lambda expressions, template strings, and parameter destructuring features.
match
is...
- exhaustive by default. Exhaustiveness means if you add a new animal, TypeScript will remind you to update the
describeAnimal
function! No more tedious guesswork.- Partial matching is available by providing a
default
property, or usingmatch
's else overload.
- Partial matching is available by providing a
- pure TypeScript. This will work on any valid discriminated union, made with
variant
or not. - well typed.
match
's return type is the union of the return types of all the handler functions. - familiar. It's meant to imitate the OCaml / Reason ML
match
statement. - flexible. By default
match
switches on thetype
property but that can easily be overridden.
Grouping​
Earlier we defined Animal
using the variantModule
function. This is often the most convenient method, but it's also perfectly valid to use the variantList()
function or to construct the Animal
object directly.
Here's
variantList()
typescript
const Animal = variantList([variant('dog', ...),variant('cat', ...),variant('snake', ...),])typescript
const Animal = variantList([variant('dog', ...),variant('cat', ...),variant('snake', ...),])
This function can also accept a string literal for a variant that has no body
- Example:
variantList(['red', 'blue', 'green'])
.
...and this is the direct approach.
typescript
const Animal = {dog: variant('dog', ...),cat: variant('cat', ...),snake: variant('snake', ...),}typescript
const Animal = {dog: variant('dog', ...),cat: variant('cat', ...),snake: variant('snake', ...),}
Feel free to mix and match styles. This is discussed further in the page on grouping variants.
Applications​
Variant is a language feature disguised as a library. As such, it's relevant to any type of application. I find myself eventually including variant in every project I write, to the point that I include it in my template repo along with my logger of choice, daslog (which also uses variant 🤣).
However there are certainly applications where variants excel.
- Actions. Variant types are the ideal solution for expressing a set of possible actions that need dispatching. That's exactly why this example is used in every conversation about discriminated unions.
- Optionals and result objects. The
Option<T>
type is familiar and loved for good reason. Variants allow you to express this and more powerful versions of result types with partial success and progress information. - Compilers and interpreters. Variants closely mirror the recursive rule definitions of S-langs. Expressing grammars in TypeScript feels natural and is feasible with this project's support for recursive and generic types.
- Heterogeneous (mixed) lists. These are the best way to express heterogeneous lists that can still be unzipped into separate, well-typed parts. Job or task systems tend to love having access to heterogeneous lists for the task queue, a list made up of different types of jobs.
Continued​
There's more to come. The next page, Motivation, is background information for new and interested readers. This next section is safe to skip. It explains why variant matters and what a vanilla TypeScript approach would look like. The Usage Guide goes over the practical things you need to know and is the next place I'd look as a new user wanting to get things done. Articles are loose writings addressing specific topics. Finally, the API Reference is available for details on every function and type.