🪴 The kind of ADTs you can count on in TypeScript
import { type Data, make } from "kind-adt";
import type { Arg0, HKT } from "hkt-core";
// Define an ADT
export type Option<T> = Data<{
Some: [value: T];
None: [];
}>;
// Generate constructors and match functions for the ADT
export const { Some, None, match } = make<OptionHKT>();
interface OptionHKT extends HKT { // <- Lift it to HKT
return: Option<Arg0<this>>;
}
function safeDivide(n: number, d: number) {
// ^?: (n: number, d: number) => Option<number>
if (d === 0) return None;
return Some(n / d);
}
// Pattern matching for ADTs
match(safeDivide(42, 2), {
Some: (n) => console.log("Result:", n),
None: () => console.log("Division by zero!"),
});
- One type to define your Algebraic Data Type (
Data
). - One function to create constructors, deconstructors, type guards and pattern matching function for your ADT with type safety (
make
). - Readable type signatures for your ADT with labeled tuples.
- Recursive ADTs with ease.
- Tiny footprint (~1kB minzipped).
- Debug your ADTs with optional utilities (
println
andshow
) fromkind-adt/utils
.
To install kind-adt via npm (or any other package manager you prefer):
npm install kind-adt
ADTs (Algebraic Data Types) are just discriminated unions in TypeScript.
import type { Data } from "kind-adt";
export type Option<T> = Data<{
Some: [value: T];
None: [];
}>;
// Expands to:
// export type Option<T> =
// | { readonly _tag: "Some"; readonly _0: T }
// | { readonly _tag: "None" }
What is a discriminated union?
These are types that can represent one of several variants, and you can determine which variant it is by looking at a special property called a discriminant (in this case, _tag
).
You can create constructors, deconstructors and type guards using the HKT (Higher-Kinded Type) of your ADT.
import { make } from "kind-adt";
import { println } from "kind-adt/utils";
import type { Arg0, HKT } from "hkt-core";
export const { Some, None, match: matchOption, isSome, ifSome /* ... */ } = make<OptionHKT>();
interface OptionHKT extends HKT {
return: Option<Arg0<this>>;
}
Some(42); // => { _tag: "Some", _0: 42 }
// ^?: <number>(args_0: number) => Option<number>
// Use `println` to print the ADT in a readable format
println(Some(42)); // Some(42)
➡️ You can use make
with non-generic ADTs, which doesn’t require an HKT.
export type IpAddr = Data<{
V4: [number, number, number, number];
V6: [string];
}>;
export const IpAddr = make<IpAddr>();
IpAddr.V4(127, 0, 0, 1); // => { _tag: "V4", _0: 127, _1: 0, _2: 0, _3: 1 }
IpAddr.V6("::1"); // => { _tag: "V6", _0: "::1" }
println(IpAddr.V4(127, 0, 0, 1)); // V4(127, 0, 0, 1)
println(IpAddr.V6("::1")); // V6("::1")
➡️ Performance note on make
make
uses proxies to “generate” related functions at runtime, which may introduce some performance overhead. If you are concerned about performance, or is developing a library that needs to be as fast as possible, you should provide the names of each variant manually, which eliminates the need for proxies.
export const Option = make<OptionHKT>(["Some", "None"]);
// Use it the same way as before
What is a Higher-Kinded Type?
You can think of it as a type-level function that takes one or more types and returns a type. In this case, OptionHKT
is a type-level function that takes one type (Arg0<this>
) and returns a type (Option<Arg0<this>>
), which can be represented as Type -> Type
.
kind-adt itself does not export an HKT implementation, but it accepts any HKT implementation that satisfies the hkt-core V1 standard. You can directly use the one exported from hkt-core, or implement your own — if neither suits your needs, just creating constructors manually is also an option.
With the magic of HKT, these generated constructors are already generic and type-safe.
// The return type is inferred as `Option<number>`
function safeDivide(n: number, d: number) {
// ^?: (n: number, d: number) => Option<number>
if (d === 0) return None;
return Some(n / d);
}
I can directly use None
as an Option
without calling it with any arguments?
Right! Constructors themselves have a _tag
property that is the discriminant of the ADT, so if a constructor has no arguments, it is already a valid variant of the ADT.
While None
is a valid Option
, you can still call it with a generic argument to change the return type in TypeScript, e.g., None<string>()
will return an Option<string>
.
Once you have your ADT, you can pattern match on it with the generated match
function!
function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return matchOption(opt, {
Some: (value) => value,
None: () => defaultValue,
});
}
It’s not real pattern matching, actually.
That’s right. Pattern matching normally includes support for nested patterns, guards, and more, but kind-adt only provides a simple pattern matching function that is more like a switch statement in TypeScript. Also check out kind-adt’s sister project megamatch for a more powerful pattern matching library that has built-in support for kind-adt style ADTs.
Sometimes I only want to match a single variant of an ADT, any syntax like if let
in Rust?
As shown in the example, make<OptionHKT>()
also generates ifSome
and ifNone
. While not mentioned in this quickstart guide, make
generates many more helper functions than just constructors and matchers, including if*
, is*
, unwrap*
, and more. Check the type guards section and the conditional deconstructors section for more details.
match
also provides a curried overload that can be useful when combined with the pipe
utility from libraries like Effect or fp-ts.
import { pipe } from "effect";
pipe(
Some(42),
matchOption({
Some: (n) => n * 2,
None: () => 0,
}),
); // => 84
Note that ADT.match
requires the return type of each case to be the same. To allow different return types, you can use the ADT.matchW
function, where the W
suffix stands for wider. ADT.matchW
can be used the same way as ADT.match
, supporting both curried and non-curried overloads.
What if I want to handle multiple variants of an ADT in a single case?
kind-adt doesn’t support this feature directly, but allows you to use a “catch-all” case to handle the remaining variants of an ADT. (while our sister project megamatch does support this feature, with built-in support for kind-adt style ADTs)
ADT.match(W)
performs exhaustiveness checking that requires you to handle all variants of an ADT. If you want to handle only some variants of an ADT and leave the rest to the default case, you can use a “catch-all” case with a _
wildcard:
matchOption(Some(42), {
Some: (n) => n * 2,
_: () => 0, // Catch-all case
});
What’s next?
- The type signatures of generated functions are very readable, but you can improve the readability of the type signatures with labeled tuples.
- Check out the syntax sugar for ADTs with only one object field and recursive ADTs.
- See how to check the type of an ADT with type guards and extract the fields with
unwrap
. - Check out the conditional deconstructors if you are tired with using
match
on a single variant with a verbose catch-all case. - See how to use optional utilities like
println
andshow
to debug your ADTs with ease.
You can extract the type of each variant of an ADT using the Tagged
utility type.
import type { Data, Tagged } from "kind-adt";
type Option<T> = Data<{
Some: [value: T];
None: [];
}>;
type Some<T> = Extract<Option<T>, Tagged<"Some">>;
// Expands to:
// type Some<T> = {
// readonly _tag: "Some";
// readonly _0: T;
// }
type None = Extract<Option<unknown>, Tagged<"None">>;
// Expands to:
// type None = {
// readonly _tag: "None"
// }
Let’s revisit the Option<T>
example in the quickstart section.
type Option<T> = Data<{
Some: [value: T];
None: [];
}>;
const Option = make<OptionHKT>();
interface OptionHKT extends HKT {
return: Option<Arg0<this>>;
}
Option.Some(42);
// ^?: <number>(args_0: number) => Option<number>
In this case, the generated constructor Some
has a type signature of <T>(args_0: T) => Option<T>
instead of the more readable <T>(value: T) => Option<T>
. This naming issue also affects other functions like Option.match
, Option.unwrap
, etc. We can improve the readability of these type signatures by using TypeScript labeled tuples.
type Option<T> = Data<
Some: [value: T],
None: [],
>;
And that’s it! Now all generated functions will have a more readable type signature.
function Option.Some<T>(value: T): Option<T>;
function Option.match<T, R>(adt: Option<T>, cases: {
readonly Some: (value: T) => R;
readonly None: () => R;
}): R;
// ...
This also applies to other ADTs, such as Result
, Either
, etc.
type Result<T, E> = Data<{
Ok: [value: T];
Err: [error: E];
}>;
type Either<A, B> = Data<{
Left: [value: A];
Right: [value: B];
}>;
If your ADT has only one object field, declaring it like this would be a little tedious:
type Tree<T> = Data<{
Empty: [];
Node: [{ value: T; left: Tree<T>; right: Tree<T> }];
}>;
// Expands to:
// type Tree<T> =
// | { readonly _tag: "Empty" }
// | { readonly _tag: "Node"; readonly _0: { value: T; left: Tree<T>; right: Tree<T> } }
To make it easier to declare, kind-adt provides syntax sugar for this case. You can declare an ADT with a bare object literal type instead of a tuple literal type. This serves as a shorthand for a tuple type with a single object field.
type Tree<T> = Data<{
Empty: {}; // In this case, `{}` is equivalent to `[]`
Node: { value: T; left: Tree<T>; right: Tree<T> };
}>;
// Expands to:
// type Tree<T> =
// | { readonly _tag: "Empty" }
// | { readonly _tag: "Node"; readonly _0: { value: T; left: Tree<T>; right: Tree<T> } }
Then you can use it like this:
interface TreeHKT extends HKT {
return: Tree<Arg0<this>>;
}
const Tree = make<TreeHKT>();
const depth: <T>(tree: Tree<T>) => number = Tree.match({
Empty: () => 0,
Node: ({ left, right }) => 1 + Math.max(depth(left), depth(right)),
});
The Tree<T>
example above is already a recursive ADT — TypeScript naturally supports recursive types when they’re defined using object literal types.
However, things get a little tricky when you want to declare the Node
variant with 3 fields (value
, left
, and right
) directly instead of using an object as the only field:
type Tree<T> = Data<{
// ~~~~
// Type alias 'Tree' circularly references itself. ts(2456)
Empty: [];
Node: [value: T, left: Tree<T>, right: Tree<T>];
}>;
This type error originates from the internal evaluation mechanism of TypeScript: types like interfaces, object literal types and function return types are “lazily” evaluated (evaluated only when necessary), while type aliases are “eagerly” evaluated (evaluated immediately). See this Stack Overflow answer for more details.
In our scenario, this means that when TypeScript tries to evaluate the Tree<T>
type alias, it will eagerly evaluate the Node
variant, which references Tree<T>
itself, causing a circular reference error.
To avoid this, kind-adt provides an alternative syntax to declare recursive ADTs like this, where an object literal type with numeric keys is used as an alternative to a tuple type:
type Tree<T> = Data<{
Empty: [];
Node: { 0: T; 1: Tree<T>; 2: Tree<T> };
}>;
➡️ Click to see how to add labels to the fields of a recursive ADT
To add labels to the fields (see Provide more readable type signatures), you can use a magical field __labels
that is a labeled tuple exists only in the type system:
type Tree<T> = Data<{
Empty: [];
Node: {
__labels: [value: void, left: void, right: void];
0: T;
1: Tree<T>;
2: Tree<T>;
};
}>;
The type of each element in __labels
does not matter, only the labels are used to provide better type signatures for the generated functions.
Then you can use it like this:
interface TreeHKT extends HKT {
return: Tree<Arg0<this>>;
}
const Tree = make<TreeHKT>();
const depth: <T>(tree: Tree<T>) => number = Tree.match({
Empty: () => 0,
Node: (_, left, right) => 1 + Math.max(depth(left), depth(right)),
});
While ADT.match(W)
is a powerful tool for handling ADTs, sometimes you simply need to check if an ADT is a specific variant and extract its value. For these cases, kind-adt provides ADT.is*
and ADT.unwrap*
functions.
const Option = make<OptionHKT>();
function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
if (Option.isSome(opt)) return opt._0;
return defaultValue;
}
However, accessing the fields of an ADT with ._${number}
like this is not very readable, so kind-adt also provides ADT.unwrap*
functions to extract the fields of an ADT into a tuple:
function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
if (Option.isSome(opt)) {
const [value] = Option.unwrap(opt);
return value;
}
return defaultValue;
}
function depth<T>(tree: Tree<T>): number {
if (Tree.isEmpty(tree)) return 0;
const [_, left, right] = Tree.unwrap(tree);
return 1 + Math.max(depth(left), depth(right));
}
You can also use ADT.unwrap*
like Option.unwrapSome
to extract the value of a specific variant of an ADT, which will throw a runtime error if the ADT is not of that variant.
A standalone unwrap
function is exported directly from kind-adt, which can be useful if you want to handle any ADT without knowing its type at compile time.
import { unwrap } from "kind-adt";
const [value] = unwrap(Some(42));
You might often need to write code like this:
Option.match(safeDivide(42, 2), {
Some: (n) => console.log("This is a very long message that I want to log", n),
_ => {},
});
Since the match
function performs exhaustiveness checking, you have to provide a catch-all case _
to handle the remaining variants of the ADT. This can be awkward and a waste of space when your codebase is full of such cases.
kind-adt provides ADT.if*
functions to handle this case more elegantly, similar to the if let
syntax in Rust:
Option.ifSome(safeDivide(42, 2), (n) => {
console.log("This is a very long message that I want to log", n);
});
If the ADT matches the specified variant, the callback function will be called with the value of that variant, otherwise nothing will happen.
The if*
function also has a return type: if the matching succeeds, the return type will be the return type of the callback function, otherwise it will be void
(undefined
in JavaScript).
const result = Option.ifSome(safeDivide(42, 2), (n) => {
// ^?: number | void
console.log("This is a very long message that I want to log", n);
return n;
});
You can also provide an optional second argument to the if*
function, which is a callback function that will be called if the matching fails. If it is provided, the return type of the if*
function will be the return type of either the first or the second callback function, depending on whether the matching succeeds or fails.
const result = Option.ifSome(
// ^?: number | string
safeDivide(42, 2),
(n) => {
console.log("This is a very long message that I want to log", n);
return n;
},
() => {
console.log("This is a very long message that I want to log");
return "default value";
},
);
Note
To use the println
and show
utilities, the showify package is required to be installed.
You can use the println
function to print an ADT in a readable format, which is especially useful for debugging.
import { println } from "kind-adt/utils";
// Suppose we have an ADT `data Tree<T> = Empty | Node(T, Tree<T>, Tree<T>)`
const tree = Tree.Node(
1,
Tree.Node(2, Tree.Node(3, Tree.Empty, Tree.Empty), Tree.Empty),
Tree.Node(4, Tree.Empty, Tree.Node(3, Tree.Empty, Tree.Empty)),
);
// ANSI colors are supported
println(tree);
// Node(
// 1,
// Node(2, Node(3, Empty, Empty), Empty),
// Node(4, Empty, Node(3, Empty, Empty))
// )
Check out show
if you want more control over the output.
The println
utility uses the showify package to convert an ADT into a string. Since println
accepts variadic arguments, it does not support passing options to control the output. If you want more control over the output, you can use the show
function to convert an ADT into a string, which accepts an optional ShowOptions
object from the showify package to control the output.
import { show } from "kind-adt/utils";
// Suppose we have an ADT `data Tree<T> = Empty | Node(T, Tree<T>, Tree<T>)`
const tree = Tree.Node(1, Tree.Node(2, Tree.Empty, Tree.Empty), Tree.Empty);
console.log(show(tree));
// Node(1, Node(2, Empty, Empty), Empty)
console.log(show(tree, { indent: 4, breakLength: 0, trailingComma: "auto" }));
// Node(
// 1,
// Node(
// 2,
// Empty,
// Empty,
// ),
// Empty,
// )
The hkt-core V1 standard is simple enough to implement by yourself. By extending HKT
exported from hkt-core, you only get two additional properties (~hkt
and signature
), which can be easily defined manually.
type Args<F> = F extends { Args: (_: infer A extends unknown[]) => void } ? A : never;
// No need to extend `HKT` ↙
interface OptionHKT {
// ↙ Required by the standard
"~hkt": { version: 1 };
// ↓ This defines the type signature (kind) of the HKT
signature: (type: unknown) => Option<unknown>;
// You can use types other than `unknown` if your type parameters are constrained,
// to provide better type safety.
// signature: (type: string | number) => Option<string | number>
// ↓ The same as before
return: Option<Args<this>[0]>;
}
// No need to extend `HKT2` ↙
interface ResultHKT {
"~hkt": { version: 1 };
// ↓ Since this HKT has two type parameters, the signature should accept two arguments
signature: (type1: unknown, type2: unknown) => Result<unknown, unknown>;
return: Result<Args<this>[0], Args<this>[1]>;
}
You can also define your own HKT
and HKT2
types if you don’t want to specify all these properties manually every time.
interface HKT<Type = unknown> {
"~hkt": { version: 1 };
signature: (type: Type) => unknown;
}
interface HKT2<Type1 = unknown, Type2 = unknown> {
"~hkt": { version: 1 };
signature: (type1: Type1, type2: Type2) => unknown;
}
Kind is a term used in type theory to describe the “type of a type”, or the “type of a type constructor (i.e. HKT)”. For example, the kind of number
, Option<string>
and Result<number, string>
are all Type
, while the kind of OptionHKT
is Type -> Type
, and the kind of ResultHKT
is (Type, Type) -> Type
.
The name kind-adt is a play on words, combining the term “kind” with “ADT” to emphasize the usage of HKTs in defining ADTs.
That’s true — instead of using "_${number}"
as field names, these libraries use a more descriptive field name like "value"
or "error"
. The design of not following this convention in kind-adt is intentional to allow a cleaner way to match multiple fields in match
without the need for object destructuring. The use of unreadable field names also encourage users to use match
instead of directly accessing fields.
Check ts-adt if you want to use a more compatible ADT library with Effect or fp-ts.
This project is licensed under the Mozilla Public License Version 2.0 (MPL 2.0).
For details, please refer to the LICENSE
file.
In addition to the open-source license, a commercial license is available for proprietary use. If you modify this library and do not wish to open-source your modifications, or if you wish to use the modified library as part of a closed-source or proprietary project, you must obtain a commercial license.
For details, see COMMERCIAL_LICENSE.md
.