This package attempts to bring Rust's fancy enums (and associated pattern matching) to TypeScript. These are sometimes referred to as algebraic data types, tagged unions, discriminated unions, disjoint unions, sum types, coproduct types or variant types. Read more about them here.
In Rust, you can use enums like this:
enum Ip {
V4(u8, u8, u8, u8),
V6(String),
}
let my_ip = Ip::V4(127, 0, 0, 1);
match my_ip {
Ip::V4(a, b, c, d) => println!("{}.{}.{}.{}", a, b, c, d),
Ip::V6(s) => println!("{}", s),
}
While you can't do this using enums in TypeScript, you can still take advantage of algebraic data types like so:
type Ip = {
tag: "V4";
value: [number, number, number, number];
} | {
tag: "V6";
value: string;
};
const myIp = {
tag: "V4",
value: [127, 0, 0, 1],
} as Ip;
const assertNever = (value: never): never => {
throw new Error(`Unexpected object: ${value}`);
};
switch (myIp.tag) {
case "V4":
const [a, b, c, d] = myIp.value;
console.log(`${a}.${b}.${c}.${d}`);
break;
case "V6":
console.log(myIp.value);
break;
default:
// Makes sure all cases are covered
assertNever(myIp);
break;
}
This is both more verbose, and a lot less ergonomic. That goes for typing the ADT, instantiating variants, matching them, and making sure you've covered all cases. This package aims to solve that.
With the exported adt
and match
functions, you'll be able to achieve (among other things) the above functionality in TypeScript, like this:
import adt, { match, type Variants } from "@korkje/adt";
const ip = adt({
v4: (a: number, b: number, c: number, d: number) => [a, b, c, d],
v6: (s: string) => s,
});
type Ip = Variants<typeof ip>;
const myIp = ip.v4(127, 0, 0, 1) as Ip;
match(myIp, {
v4: ([a, b, c, d]) => console.log(`${a}.${b}.${c}.${d}`),
v6: s => console.log(s),
});
This is only scratching the surface of what you can do with algebraic data types. If this sounds interesting to you, read on!
# deno
deno add @korkje/adt
# npm (and friends)
npx jsr add @korkje/adt
yarn dlx jsr add @korkje/adt
pnpm dlx jsr add @korkje/adt
bunx jsr add @korkje/adt
In the most simple case, adt
can be used as a drop-in replacement for TypeScript's enums:
import adt from "@korkje/adt";
const powerStatus = adt({
on: null,
off: null,
});
In this case, powerStatus
' properties are the variants of the enum. The null
primitive is used to indicate that the variant has no associated data. If you want to associate data with a variant, jump right to the fun part: beyond enums.
If you have a function that should return a variant, you can extract the union of the variants using the Variants
type, which is useful if you want to set an explicit return type for the function:
import type { Variants } from "@korkje/adt";
type PowerStatus = Variants<typeof powerStatus>;
const getPowerStatus = (): PowerStatus =>
Math.random() > 0.5
? powerStatus.on
: powerStatus.off;
The variants themseves are intentionally not typed as the union of all variants, which is useful in a lot of cases. For instance, you might want to implicitly narrow the return type of a function based on only the variant or variants it can return.
When you've got your hands on a variant, you can use the match
function to determine which one it is:
import { match } from "@korkje/adt";
const currentPowerStatus = getPowerStatus();
match(currentPowerStatus, {
on: () => console.log("The power is on"),
off: () => console.log("The power is off"),
});
If you want, you can omit one or more cases and specify a default case def
Symbol
:
const color = adt({
red: null,
green: null,
blue: null,
});
const getColor = (): Variants<typeof color> =>
// ...
match(getColor(), {
red: () => console.log("red"),
[def]: () => console.log("blue or green"),
});
As briefly mentioned above, if you don't explicitly type the return type of a function using Variants
, it will be the narrowed to only the relevant variants. Say you have a function that returns a red
or green
variant:
const getColor = () =>
Math.random() > 0.5
? color.red
: color.green;
In this case, the return type of the function is inferred as only the red or green variant, and the match
function will not expect a blue
case:
match(getColor(), {
red: () => console.log("red"),
green: () => console.log("green"),
// Not allowed:
// blue: () => console.log("blue"),
});
So, if you want to use Variants
or not will depend on your use case. You might want to ensure that all possible variants of the ADT are handled, in case your function changes in the future, or you might want to narrow the return type of the function to only the relevant variants.
Like in Rust, match
also returns the result of the function that matched the variant:
const isOn = match(currentPowerStatus, {
on: () => true,
off: () => false,
});
The return type of the match
function is the union of the return types of the functions passed to it, so using as const
can be useful for narrowing the type:
// Returns: string
match(currentPowerStatus, {
on: () => "on",
off: () => "off",
});
// Returns: "on" | "off"
match(currentPowerStatus, {
on: () => "on" as const,
off: () => "off" as const,
});
Where TypeScript's enums are limited to encapsulating strings or numbers, adt
allows you to associate arbitrary data with variants by providing "creator" functions:
const powerStatus = adt({
on: (voltage: number) => voltage,
off: null,
});
The match
function can then be used to extract the data:
const U = match(currentPowerStatus, {
on: voltage => voltage,
off: () => null,
});
The type of U
in this case is number | null
.
The variant itself is passed to the matching function as a second argument, if you need it:
const U = match(currentPowerStatus, {
on: (_, variant) => variant,
off: () => null,
});
console.log(U.value); // 230
In this case, the type of U
is ["on", number] | null
, where ["on", number]
is the a variant with tag "on"
and value number
. More generically, the tuple [T, V]
is the internal representation a variant with tag T
and value V
. A tuple is used instead of an object (e.g. on the form { tag: T, value: V }
) simply to reduce serialization size.
As it is only the return type of the creator function that determines the type of the variant's associated data, you can get creative with it:
const powerStatus = adt({
on: (current: number, resistance: number) => ({
U: current * resistance,
I: current,
R: resistance,
}),
off: null,
});
const U = match(currentPowerStatus, {
on: ({ U }) => U,
off: () => null,
});
If you use the def
symbol to specify a default case when one or more variants have associated data, the parameter passed to the default case will be correctly typed as the union of the associated data types that don't have explicit matchers:
import adt, { match, def, type Variants } from "@korkje/adt";
const housing = adt({
house: (floors: number, rooms: number) => ({ floors, rooms }),
apartment: (rooms: number) => number,
tent: null,
});
const getHousing = (): Variants<typeof housing> =>
// ...
match(getHousing(), {
house: ({ floors, rooms }) => console.log(`House: ${floors} floors, ${rooms} rooms`),
[def]: value => console.log(value),
});
In the above example, the type of value
(in the default matcher) will be correctly inferred as number | null
. Notice that null
is included in the inferred type, even though the tent
variant has no associated data. This is because variants with no associated data have a value of null
.
You can also nest ADTs inside one another:
const acStatus = adt({
on: (voltage: number) => voltage,
off: null,
});
type ACStatus = Variants<typeof acStatus>;
const powerSource = adt({
battery: (voltage: number) => voltage,
ac: (status: ACStatus) => status,
});
type PowerSource = Variants<typeof powerSource>;
const getPowerSource = (): PowerSource =>
Math.random() > 0.5
? powerSource.battery(12)
: powerSource.ac(acStatus.on(230));
For a simple case like this, there is support for a simpler syntax:
const powerSource = adt({
battery: (voltage: number) => voltage,
ac: {
on: (voltage: number) => voltage,
off: null,
},
});
type PowerSource = Variants<typeof powerSource>;
const getPowerSource = (): PowerSource =>
Math.random() > 0.5
? powerSource.battery(12)
: powerSource.ac.on(230);
However, in the following example, the simpler syntax would not work, because the ac
variant's associated data isn't only a nested ADT:
const acStatus = adt({
on: null,
off: null,
});
type ACStatus = Variants<typeof acStatus>;
const powerSource = adt({
battery: (voltage: number) => voltage,
ac: (status: ACStatus, voltage: number) => ({ status, voltage }),
});
Using the match
function, decoding nested ADTs is a breeze:
const U = match(getPowerSource(), {
battery: voltage => voltage,
ac: status => match(status, {
on: voltage => voltage,
off: () => null,
}),
});
One could envision a more concise syntax for this, in line with the simpler syntax for creating nested ADTs, but I am already stretching the capabilities of the type system (and my brain) as it is.
Anyway, something like this would be nice:
const U = match(getPowerSource(), {
battery: voltage => voltage,
// Does NOT work, but would be nice:
ac: { // <-- this is the only difference
on: voltage => voltage,
off: () => null,
},
});
You can go as deep as you want with nested ADTs:
const activity = adt({
idle: null,
moving: {
running: {
sprinting: null,
jogging: null,
},
driving: null,
},
});
type Activity = Variants<typeof activity>;
const myActivity = activity.moving.running.sprinting as Activity;
const res = match(sprinting, {
idle: () => "idle",
moving: (mode) => match(mode, {
running: (intensity) => match(intensity, {
sprinting: () => "sprinting",
jogging: () => "jogging",
}),
driving: () => "driving",
}),
});
console.log(res); // "sprinting"
If you want to create a more complex ADT that for instance needs to be recursive and/or generic, you could do this without using the adt
function at all:
import { variant, match } from "@korkje/adt";
type LL<T> =
| ["nil", null]
| ["cons", readonly [T, LL<T>]];
const nil = variant("nil", null);
const cons = <T>(h: T, t: LL<T>) => variant("cons", [h, t] as const);
const list: LL<number> = cons(1, cons(2, cons(3, nil)));
const llToArr = <T>(ll: LL<T>): T[] => match(ll, {
nil: () => [],
cons: ([h, t]) => [h, ...llToArr(t)],
});
console.log(llToArr(list)); // [1, 2, 3]
adt
also exports two useful types: Option
and Result
. These are similar to Rust's Option
and Result
types.
Option
is a type that can either be some
or none
. It is useful for representing the possibility of a value not existing.
import { type Option, some, none } from "@korkje/adt";
const getOption = (): Option<number> =>
Math.random() > 0.5
? some(42)
: none;
const option = getOption();
const value = match(option, {
some: value => value,
none: () => null,
});
Result
is a type that can either be ok
or err
. It is useful for representing the possibility of a function failing.
import { type Result, ok, err } from "@korkje/adt";
const getResult = (): Result<number, Error> =>
Math.random() > 0.5
? ok(42)
: err(new Error("Something went wrong!"));
const result = getResult();
const value = match(result, {
ok: value => value,
err: error => {
console.error(error);
return null;
},
});
ifLet
is a helper function inspired by Rust's if let, that can be used to match a variant and call a function with the associated data if the variant matches:
import { ifLet } from "@korkje/adt";
ifLet(getPowerSource(), "battery", voltage => {
console.log(`Battery voltage: ${voltage}`);
});
letElse
is a helper function inspired by Rust's let-else, that can be used to match a variant, or call a function and throw if the variant doesn't match:
import { letElse } from "@korkje/adt";
const voltage = letElse(getPowerSource(), "battery", () => {
throw new Error("Not a battery!");
});
unwrap
, unwrapErr
, unwrapOr
and unwrapOrElse
are helper functions inspired by those exposed by the Option
and/or Result
types in Rust. Similarly, these work on the previously mentioned Option
and/or Result
types. The purpose of these functions is to extract the associated data from a variant, or (in some cases) to throw an error if the variant is none
or err
.
unwrap
simply extracts the associated data from a variant if it is some
or ok
, or throws an error if it is none
or err
:
import { unwrap } from "@korkje/adt";
const value = unwrap(getOption());
unwrapErr
is similar to unwrap
, except it extracts the error from a Result
variant if it is err
, or throws an error if it is ok
:
import { unwrapErr } from "@korkje/adt";
const error = unwrapErr(getResult());
unwrapOr
is also similar to unwrap
, but it takes a default value as its second argument, which is returned if the variant is none
or err
:
import { unwrapOr } from "@korkje/adt";
const value = unwrapOr(getOption(), 0);
unwrapOrElse
is similar to unwrapOr
, but it takes a function as its second argument, which is used to construct the default value if the variant is none
or err
:
import { unwrapOrElse } from "@korkje/adt";
const value = unwrapOrElse(getOption(), () => 0);
expect
and expectErr
are very similar to unwrap
and unwrapErr
, but they take a message as their second argument, which is used to construct the error that is thrown if the variant is none
or err
:
import { expect, expectErr } from "@korkje/adt";
const value = expect(getOption(), "No value!");
const error = expectErr(getResult(), "No error!");