Skip to content

Snowflyt/kind-adt

Repository files navigation

kind-adt

🪴 The kind of ADTs you can count on in TypeScript

downloads npm version minzipped size coverage status MPL-2.0 license

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!"),
});

Features

  • 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 and show) from kind-adt/utils.

Installation

To install kind-adt via npm (or any other package manager you prefer):

npm install kind-adt

Quickstart

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?

Recipes

Extract variant types

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"
// }

Provide more readable type signatures

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];
}>;

Syntax sugar for ADTs with only one object field

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)),
});

Recursive ADTs

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)),
});

Type guards and unwrap

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));

Conditional deconstructors (if*)

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";
  },
);

Optional Utilities

Note

To use the println and show utilities, the showify package is required to be installed.

println

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.

show

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,
// )

FAQ

I don’t want to install another package for HKT. Can I implement my own?

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;
}

Why “Kind”?

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.

ADTs in kind-adt are incompatible with those in Effect or fp-ts!

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.

License

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.