Skip to main content
Version: 3.0.0-dev 🔨

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 domains and problem spaces. 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.

🧠 Click here to jump straight to the API Reference

Quick start

Variant has zero dependencies and doesn't need any setup.

bash
yarn add variant@dev # npm install --save variant@dev
bash
yarn add variant@dev # npm install --save variant@dev

Let's use variant() to describe a simple domain — Animals. Much like an enum, our Animal variant can be in one of several forms. Unlike an enum, our variant can carry data with each of its forms.

For the game we're making, our player can have a pet cat, dog, or snake. We have different concerns for each animal, so we'll want to define them with distinct fields. The fields() function is a useful shorthand here. We'll see more of how it works in the first section of the User Guide.

If there's a need for more control over the creation process, including default inputs, side effects, or other complex logic, define the case with a simple function. We'll do so for the snake constructor to give it a default skin pattern of 'striped'.

animal.ts
ts
import {variant, fields, VariantOf} from 'variant';
 
export const Animal = variant({
cat: fields<{name: string, furnitureDamaged: number}>(),
dog: fields<{name: string, favoriteBall?: string}>(),
snake: (name: string, pattern: string = 'striped') => ({name, pattern}),
});
export type Animal = VariantOf<typeof Animal>;
animal.ts
ts
import {variant, fields, VariantOf} from 'variant';
 
export const Animal = variant({
cat: fields<{name: string, furnitureDamaged: number}>(),
dog: fields<{name: string, favoriteBall?: string}>(),
snake: (name: string, pattern: string = 'striped') => ({name, pattern}),
});
export type Animal = VariantOf<typeof Animal>;

Let's take a closer look at that last line.

ts
export type Animal = VariantOf<typeof Animal>;
type Animal = { type: "cat"; name: string; furnitureDamaged: number; } | { type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "snake"; name: string; pattern: string; }
ts
export type Animal = VariantOf<typeof Animal>;
type Animal = { type: "cat"; name: string; furnitureDamaged: number; } | { type: "dog"; name: string; favoriteBall?: string | undefined; } | { type: "snake"; name: string; pattern: string; }

🖱️ Mouse users can hover over any term to see types thanks to shiki-twoslash.

Those familiar with TypeScript's discriminated unions will recognize this pattern. The goal of this library is not to replace discriminated unions, but rather to provide the best possible way to work with them.

The power of variant()

Well... to work with them, and to build on them. The tools Variant provides offer more convenience, safety, and utility than the vanilla approach. Traditionally, a developer would define each form of a discriminated union as its own type, write a factory function for each of those types, and then manually place these types in a union (type Animal = Dog | Cat | Snake).

This process is error prone, repetitive, and confusing to new developers. Each of these problems multiply with scale. Adding a new case requires modifications in multiple locations and duplication of effort. Variant is based on the idea that the subtype (Animal<'dog'>) can be inferred from the return type of the dog constructor, and that the larger type Animal can be calculated from a set of these constructor functions. By grouping our cases in one place, we define the bounds of our domain, automate the relation of these types, and provide clarity of intent.

But it's not just in how we construct the variants. The experience as a consumer is significantly improved. There's a lot that can be done with this single import. Because we used the name Animal for the value and the type, this statement will import both.

ts
import {Animal} from './animal';
ts
import {Animal} from './animal';

Create new instances

With the object Animal (a collection of tag constructors) we can easily create new instances, even if custom logic is involved.

ts
const cerberus = Animal.dog({name: 'Cerberus'});
const steve = Animal.snake('Steve'); // Steve will have striped skin.
ts
const cerberus = Animal.dog({name: 'Cerberus'});
const steve = Animal.snake('Steve'); // Steve will have striped skin.

Type the Union

With the type Animal, we can describe the shape of the union. It's our way of saying this is some animal, but we don't know which one. We can still access those properties shared among all animals, like the name.

ts
declare var animal: Animal;
const type = animal.type;
const name = animal.name;
ts
declare var animal: Animal;
const type = animal.type;
const name = animal.name;

Type specific instances

We can access specific elements of the union

ts
declare var dog: Animal<'dog'>;
var dog: { type: "dog"; name: string; favoriteBall?: string | undefined; }
ts
declare var dog: Animal<'dog'>;
var dog: { type: "dog"; name: string; favoriteBall?: string | undefined; }

⚠️ This requires the full type annotation

or define subsets:

ts
type furryAnimal = Animal<'cat'> | Animal<'dog'>; // no snake
type furryAnimal2 = Animal<'cat' | 'dog'>; // same thing
ts
type furryAnimal = Animal<'cat'> | Animal<'dog'>; // no snake
type furryAnimal2 = Animal<'cat' | 'dog'>; // same thing

Narrow from the union to specific instances

TypeScript's narrowing will work as expected, constraining the union to its specific element.

ts
declare var animal: Animal;
 
if (animal.type === 'dog') {
// can access dog-specific properties
const favoriteBall = animal.favoriteBall;
}
ts
declare var animal: Animal;
 
if (animal.type === 'dog') {
// can access dog-specific properties
const favoriteBall = animal.favoriteBall;
}

This is also available as isType(), which can accept as input 'cat' | 'dog' | 'snake', or one of the animal constructors.

ts
if (isType(animal, Animal.dog)) {
const favoriteBall = animal.favoriteBall;
}
ts
if (isType(animal, Animal.dog)) {
const favoriteBall = animal.favoriteBall;
}
Processing these types

Now that we can express the types we'll run into in our code, we can move on to handling them. Any of the familiar methods will work—if statements, switch statements, maps or lookup tables—but I would argue the most effective approach is pattern matching.

  • Unlike an if statement, multiple cases can be handled all at once.
  • Unlike the if/switch statement(s)
    • the match action is exhaustive by default.
    • the match functions are expressions and can be used inline or in JSX.
  • Unlike maps and lookup tables, no explicit types are required.

Make me a match() (🎻)

Variant's match functions allow us to directly express our response to each scenario. We can leverage this case-specific logic to make decisions or to render things differently depending on the value at runtime.

Let's help our player's rival decide on whether or not they want a specific animal as a pet. They are allergic to dogs, so dogs are a no-go. Cats are nice but the rival can't afford to replace furniture, so the cat has to have a history of good behavior. Any snake will do.

ts
function rivalWantsAnimal(animal: Animal) {
return match(animal, {
dog: _ => false,
cat: ({furnitureDamaged}) => furnitureDamaged < 3,
snake: _ => true,
})
}
ts
function rivalWantsAnimal(animal: Animal) {
return match(animal, {
dog: _ => false,
cat: ({furnitureDamaged}) => furnitureDamaged < 3,
snake: _ => true,
})
}

We can now use this function to evaluate some animal we come across, or perhaps even use it to filter a list of animals.

ts
const potentialPets = animals.filter(rivalWantsAnimal);
ts
const potentialPets = animals.filter(rivalWantsAnimal);

Even though we never gave a return type for rivalWantsAnimal, match() has been written to work with TypeScript's type inference, allowing the language to infer boolean as the correct return type.

Conditional Rendering

A simple form of conditional rendering would be to describe how an animal is relaxing. Each description could be a simple line or something more complex, and it could involve information about the animal.

ts
const describeAnimal = (animal: Animal) => match(animal, {
cat: ({name}) => `${name} is lounging on the windowsill.`,
dog: ({name, favoriteBall}) => (
[
`${name} is on the rug`,
favoriteBall != undefined
? `, nuzzling a ${favoriteBall} ball.`
: '.'
].join('')
),
snake: ({name}) => `${name} is basking in the heat of the sun.`
})
 
const cerberus = Animal.dog({name: 'Cerberus', favoriteBall: 'red'});
 
console.log(describeAnimal(cerberus));
// Cerberus is on the rug, nuzzling a red ball.
 
ts
const describeAnimal = (animal: Animal) => match(animal, {
cat: ({name}) => `${name} is lounging on the windowsill.`,
dog: ({name, favoriteBall}) => (
[
`${name} is on the rug`,
favoriteBall != undefined
? `, nuzzling a ${favoriteBall} ball.`
: '.'
].join('')
),
snake: ({name}) => `${name} is basking in the heat of the sun.`
})
 
const cerberus = Animal.dog({name: 'Cerberus', favoriteBall: 'red'});
 
console.log(describeAnimal(cerberus));
// Cerberus is on the rug, nuzzling a red ball.
 

If this seems unfamiliar, take a look at ES6's lambda expression, template string, and parameter destructuring features.

Just like before, TypeScript understands the return type of describeAnimal. It has been correctly inferred as string. We'll see a more detailed example of conditional rendering when we enter the React portions of the tutorial. Rest assured, Variant isn't a react library and doesn't actually have any react dependencies. Despite that fact, the two work together seamlessly thanks to a shared focus on functional principles and composability.

Compatability

By that same token, because variants are discriminated unions and TypeScript understands them as such, match() doesn't actually need its target to have been created by variant(). Match can easily operate on the discriminated unions that are already within your code, and will work with models written by codegen. Any valid discriminated union will work.

Exhaustiveness and Partial Matching

Matching is exhaustive by default, meaning that a user must handle every possibility before the compiler will be satisfied. As our result, our code is much more resilient. If we were to add a new type of Animal to the game, say a bird, then every place in the code we match against Animal will raise a compiler error. Don't mistake them, these errors are a beautiful thing—they are step-by-step instructions on how to safely incorporate bird logic into the codebase.

ts
import {variant, fields, VariantOf} from 'variant';
 
export const Animal = variant({
cat: fields<{name: string, furnitureDamaged: number}>(),
dog: fields<{name: string, favoriteBall?: string}>(),
snake: (name: string, pattern: string = 'striped') => ({name, pattern}),
bird: fields<{name: string, canFly: boolean}>(),
});
ts
import {variant, fields, VariantOf} from 'variant';
 
export const Animal = variant({
cat: fields<{name: string, furnitureDamaged: number}>(),
dog: fields<{name: string, favoriteBall?: string}>(),
snake: (name: string, pattern: string = 'striped') => ({name, pattern}),
bird: fields<{name: string, canFly: boolean}>(),
});

With the bird type in place, autocomplete can help guide us implement our handlers. In VS Code, type <ctrl>+<space> to show the remaining cases.

match autocomplete showing &#39;bird&#39; and &#39;default&#39;

With the new handler in place, the compiler is happy.

ts
function rivalWantsAnimal(animal: Animal) {
return match(animal, {
dog: _ => false,
cat: ({furnitureDamaged}) => furnitureDamaged < 3,
snake: _ => true,
bird: ({canFly}) => canFly, // rival only wants flying birds
})
}
ts
function rivalWantsAnimal(animal: Animal) {
return match(animal, {
dog: _ => false,
cat: ({furnitureDamaged}) => furnitureDamaged < 3,
snake: _ => true,
bird: ({canFly}) => canFly, // rival only wants flying birds
})
}

However, there will be times when we don't want to handle each case individually, or may only be concerned with a subset of the cases. For those situations, we have partial matching. We activate it with the partial helper function. Let's create a function to decide on animals for a person who is obsessed with snakes.

ts
const slytherinFanWantsAnimal = (hopefullyASnake: Animal) =>
match(hopefullyASnake, partial({
snake: constant(true), // same as () => true, but nicer to fingers.
default: just(false), // same as 'constant(false)'. Your choice.
}))
ts
const slytherinFanWantsAnimal = (hopefullyASnake: Animal) =>
match(hopefullyASnake, partial({
snake: constant(true), // same as () => true, but nicer to fingers.
default: just(false), // same as 'constant(false)'. Your choice.
}))
A faster way

In this special case where we are asking "is this a snake", match is overkill. We can use isType(hopefullyASnake, 'snake').

Harder Better Faster matcher() (🎸)

There's an important scenario that is not possible with a regular match statement, but is possible with the traditional switch. By taking advantage of fallthrough cases, a switch statement can handle multiple cases at once, also known as multi-matching.

ts
const hasFur = (animal: Animal) => {
switch(animal.type) {
case 'cat':
case 'dog':
return true;
case 'snake':
return false;
}
}
ts
const hasFur = (animal: Animal) => {
switch(animal.type) {
case 'cat':
case 'dog':
return true;
case 'snake':
return false;
}
}

To access this feature with the benefits of variant's pattern matching, use the matcher() function.

This builder-pattern alternative to match can construct the handler piece by piece. When all cases are handled, finish off with a .complete() statement (or other terminal) to execute the handler.

ts
const hasFur = (animal: Animal) => matcher(animal)
.when(['cat', 'dog'], _ => true)
.when('snake', _ => false)
.complete();
ts
const hasFur = (animal: Animal) => matcher(animal)
.when(['cat', 'dog'], _ => true)
.when('snake', _ => false)
.complete();

Improved error reporting.

The matcher() function has better error reporting than match(). When cases are missing, it will raise an error explaining exactly which ones.

ts
const hasFur = (animal: Animal) => matcher(animal)
.when('snake', _ => false)
.complete();
This expression is not callable. Type 'VariantError<["The handler has not been fully completed. Expected key(s)", "cat" | "dog"]>' has no call signatures.2349This expression is not callable. Type 'VariantError<["The handler has not been fully completed. Expected key(s)", "cat" | "dog"]>' has no call signatures.
ts
const hasFur = (animal: Animal) => matcher(animal)
.when('snake', _ => false)
.complete();
This expression is not callable. Type 'VariantError<["The handler has not been fully completed. Expected key(s)", "cat" | "dog"]>' has no call signatures.2349This expression is not callable. Type 'VariantError<["The handler has not been fully completed. Expected key(s)", "cat" | "dog"]>' has no call signatures.

Built-in tools

There are many options to the matcher(). For instance, it can act as a lookup table, removing the need for constant()/just().

ts
const animalEmoji = (animal: Animal) => matcher(animal)
.lookup({
cat: '🐱',
dog: '🐕',
snake: '🐍',
})
ts
const animalEmoji = (animal: Animal) => matcher(animal)
.lookup({
cat: '🐱',
dog: '🐕',
snake: '🐍',
})

Scaling up

As the number of cases in a variant grows into the dozens, hundreds, or even thousands, it will become necessary to refactor these elements and handle them one subset at a time. The matcher is the ideal solution to delegate this handling based on these groups.

Animals, when it comes to domain modeling, are a bit like the arithmetic of a coding tutorial. They demonstrate the structures, but more often your real-life programs will be more complex. In redux applications, for example, it's common to have a reducer, a function that takes a state and some descriptive action (move to this tile, kill this enemy) and returns a new state. As an application grows, it becomes necessary to separate reducer logic into smaller chunks. This is no problem.

ts
export const rootReducer = (state = initState, action: Action) => {
return matcher(action)
.when(types(DebugAction), _ => debugReducer(state, _))
.when(types(AppAction), _ => appReducer(state, _))
.when(types(GameAction), _ => ({...state, game: gameReducer(state, _))})
.else(just(state)); // ignore redux init actions
}
ts
export const rootReducer = (state = initState, action: Action) => {
return matcher(action)
.when(types(DebugAction), _ => debugReducer(state, _))
.when(types(AppAction), _ => appReducer(state, _))
.when(types(GameAction), _ => ({...state, game: gameReducer(state, _))})
.else(just(state)); // ignore redux init actions
}

My game logic (GameAction/gameReducer) is separated from my state saving and loading logic (AppAction/appReducer), which are both further separated from my developer cheat codes (DebugAction/debugReducer). As the game grows in complexity, the gameReducer may itself delegate to more and more specific functions, all orchestrated through pattern matching.

Variant Applications

Variant is a language feature disguised as a library. As such it's relevant to any type of application, and at only 3kb zipped it's become very easy to include. I find myself using variant in every project I write, to the point that I import it in my personal template.

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. It will come up in this documentation as well, as soon as the next section, in fact.
  • Optionals and result objects. The Option<T> type is familiar and beloved for good reason as an alternative to null handling. Variants allow us to express this and even 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.

Variant is smoothly incorporated into existing codebases and should not be relegated to new projects. Recall that variant types simply are discriminated unions. There's very little magic in the product, just in its construction. Users may start refactoring from any part of their application while retaining a working build.

You may wish to refactor your action creators yet continue processing them with the same old switch statement. Or take the opportunity to refresh your complex switch statements with the cleaner organization of match()/matcher() while leaving your current constructors in place. Some utilize the match functions but leave type creation entirely to codegen with GraphQL or protobuf.

Flexibility has been coded into Variant 3.0 from the gound up. The variants we saw were created with type as the discriminant, but this is simply the default. Every variant function can be keyed to a completely different common term, allowing the same level of functionality as type for unions that pivot on kind, tag, or GraphQL's __typename.

ts
const {variant, match} = variantCosmos('kind'); // is all it takes
ts
const {variant, match} = variantCosmos('kind'); // is all it takes

The software is open-source, actively maintained, and matures with each update. Every function in the library has its own test suite and documentation. This package has been trusted by individuals, corporations, and governments for mission-critical applications.

Next Steps

Thanks for sticking around to this point. The remainder of the documentation is broken down into the following sections to help intuitively understand the library. I recommend the tutorial. Click here, or the Next button below.

  • The Tutorial: An exploration into building "Kind of Super", a text-based browser game that leverages variant to simplify a lot of the complexity in becoming a superhero and protecting a city from superpowered threats. Built on standard web technologies and eventually incorporating React + Redux, "Kind of Super" demonstrates exactly how to integrate variant into a codebase with real-world tooling.
  • The Book: A deep-dive into everything that's possible with these tools, and some things that aren't.
  • Articles: Assorted content. Things that are useful to know but don't fit well in a category.
  • Libraries: Comparisons to competitors, or How-To guides for compatible packages.
  • API: A full API reference for the library. Every function, every type, every meaning.
  • Glosssary: A consolidated dictionary of the esoteric terms I use.