Skip to main content
Version: 2.1.0

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 using match's else overload.
  • 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 the type property but that can easily be overridden.
note

match has a little sister named lookup, for when you don't need to use any properties from the variant.

typescript
const cuteName = lookup(animal, {
cat: 'kitty',
dog: 'pupper',
snake: 'snek',
});
typescript
const cuteName = lookup(animal, {
cat: 'kitty',
dog: 'pupper',
snake: 'snek',
});

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.