ts-union

A tiny library for algebraic sum types in typescript. Inspired by unionize and F# discriminated-unions (and other ML languages)

Installation

npm add ts-union

NOTE: Distrubuted as modern javascript (es2018) library.

Usage

Define

import { Union, of } from 'ts-union';

const PaymentMethod = Union({
  Check: of<CheckNumber>(),
  CreditCard: of<CardType, CardNumber>(),
  Cash: of(null), // means that this variant has no payload
});

type CheckNumber = number;
type CardType = 'MasterCard' | 'Visa';
type CardNumber = string;

Construct a union value

// Check is a function that accepts a check number
const check = PaymentMethod.Check(15566909);

// CreditCard is a function that accepts two arguments (CardType, CardNumber)
const card = PaymentMethod.CreditCard('Visa', '1111-566-...');

// Cash is just a value
const cash = PaymentMethod.Cash;

// or destructure it to simplify construction :)
const { Cash, Check, CreditCard } = PaymentMethod;
const anotherCheck = Check(566541123);

match

const str = PaymentMethod.match(cash, {
  Cash: () => 'cash',
  Check: (n) => `check num: ${n.toString()}`,
  CreditCard: (type, n) => `${type} ${n}`,
});

Also supports deferred (curried) matching and default case.

const toStr = PaymentMethod.match({
  Cash: () => 'cash',
  default: (_v) => 'not cash', // _v is the union obj
});

const str = toStr(card); // "not cash"

if (aka simplified match)

const str = PaymentMethod.if.Cash(cash, () => 'yep'); // "yep"
// typeof str === string | undefined

You can provide else case as well, in that case 'undefined' type will be removed from the result.

// typeof str === string
const str = PaymentMethod.if.Check(
  cash,
  (n) => `check num: ${n.toString()}`,
  (_v) => 'not check' // _v is the union obj that is passed in
); // str === 'not check'

EXPERIMENTAL matchWith

WARNING: This API is experimental and currently more of an MVP.

Often we want to match a union with another union. A good example of this if we try to model a state transition in useReducer in React or model a state machine.

This is what you have to do currently:

const State = Union({
  Loading: of(null),
  Loaded: of<number>(),
  Err: of<string>(),
});

const Ev = Union({
  ErrorHappened: of<string>(),
  DataFetched: of<number>(),
});

const { Loaded, Err, Loading } = State;

const transition = (prev: typeof State.T, ev: typeof Ev.T) =>
  State.match(prev, {
    Loading: () =>
      Ev.match(ev, {
        ErrorHappened: (err) => Err(err),
        DataFetched: (data) => Loaded(data),
      }),

    Loaded: (loadedData) =>
      // just add to the current loaded value as an example
      Ev.if.DataFetched(
        ev,
        (data) => Loaded(loadedData + data),
        () => prev
      ),

    default: (s) => s,
  });

It gets worse and more verbose when complexity grows, also you have to match the Ev in each variant of State.

In my experience this comes up often enough to justify a dedicated API for matching a pair:

import { Union, of } from 'ts-union';

const State = Union({
  Loading: of(null),
  Loaded: of<number>(),
  Err: of<string>(),
});

const Ev = Union({
  ErrorHappened: of<string>(),
  DataFetched: of<number>(),
});

const { Loaded, Err, Loading } = State;

const transition = State.matchWith(Ev, {
  Loading: {
    ErrorHappened: (_, err) => Err(err),
    DataFetched: (_, data) => Loaded(data),
  },

  Loaded: {
    DataFetched: (loaded, data) => Loaded(loaded + data),
  },

  default: (prevState, ev) => prevState,
});

// usage
const newState = transition(Loading, Ev.ErrorHappened('oops')); // <-- State.Err('oops')

transition is a function with type signature: (prev: State, ev: Ev) => State. Note that the return type is inferred, meaning that you can return whatever type you want :)

const logLoadingTransition = State.matchWith(Ev, {
  Loading: {
    ErrorHappened: (_, err) => 'Oops, error happened: ' + err,
    DataFetched: (_, data) => 'Data loaded with: ' + data.toString(),
  },
  default: () => '',
});

Caveats

  1. Doesn't support generic version (yet?)
  2. Doesn't work with unions that have more than 1 arguments in variants. E.g. of<string, number>() will give an incomprehensible type error.
  3. You cannot pass additional data to the update function. I'm tinkering about something like this for the future releases:
const transition = State.matchWith(Ev, {...}, of<SomeContext>());
transition = (prev, ev, someContextValue);

Two ways to specify variants with no payload

You can define variants with no payload with either of(null) or of<void>();

const Nope = Union({
  Old: of<void>(), // only option in 2.0
  New: of(null), // new syntax in 2.1
});

// Note that New is a value not a function
const nope = Nope.New;

// here Old is a function
const oldNope = Nope.Old();

Note that Old will always allocate a new value while New is a value (thus more efficient).

For generics the syntax differs a little bit:

// generic version
const Option = Union((t) => ({
  None: of(null),
  Some: of(t),
}));

// we need to provide a type for the Option to "remember" it.
const maybeNumber = Option.None<number>();

Even though None is a function, but it always returns the same value. It is just a syntax to "remember" the type it was constructed with;

Speaking of generics...

Generic version

// Pass a function that accepts a type token and returns a record
const Maybe = Union((val) => ({
  Nothing: of(null), // type is Of<[Unit]>
  Just: of(val), // type is Of<[Generic]>
}));

Note that val is a value of the special type Generic that will be substituted with an actual type later on. It is just a variable name, pls feel free to name it whatever you feel like :) Maybe a, T or TPayload?

This feature can be handy to model network requests (like in Redux):

const ReqResult = Union((data) => ({
  Pending: of(null),
  Ok: of(data),
  Err: of<string | Error>(),
}));

// res is inferred as UnionValG<string, ...>
const res = ReqResult.Ok('this is awesome!');

const status = ReqResult.match(res, {
  Pending: () => 'Thinking...',
  Err: (err) =>
    typeof err === 'string' ? `Oops ${err}` : `Exception ${err.message}`,
  Ok: (str) => `Ok, ${str}`,
}); // 'Ok, this is awesome!'

Let's try to build map and bind functions for Maybe:

const { Nothing, Just } = Maybe;

// GenericValType is a helper that allows you to substitute Generic token type.
type MaybeVal<T> = GenericValType<T, typeof Maybe.T>;

const map = <A, B>(val: MaybeVal<A>, f: (a: A) => B) =>
  Maybe.match(val, {
    Just: (v) => Just(f(v)),
    Nothing: () => Nothing<B>(), // note that we have to explicitly provide B type here
  });

const bind = <A, B>(val: MaybeVal<A>, f: (a: A) => MaybeVal<B>) =>
  Maybe.if.Just(
    val,
    (a) => f(a),
    (n) => (n as unknown) as MaybeVal<B>
  );

map(Just('a'), (s) => s.length); // -> Just(1)
bind(Just(100), (n) => Just(n.toString())); // -> Just('100')

map(Nothing<string>(), (s) => s.length); // -> Nothing

And if you want to extend Maybe with these functions:

const TempMaybe = Union(val => ({
  Nothing: of(),
  Just: of(val)
}));

const map = .....
const bind = .....

// TempMaybe is just an object, so this is perfectly legit
export const Maybe = {...TempMaybe, map, bind};

Type of resulted objects

Types of union values are opaque. That makes it possible to experiment with different underlying data structures.

type CashType = typeof cash;
// UnionVal<{Cash:..., Check:..., CreditCard:...}>
// and it is the same for card and check

The UnionVal<...> type for PaymentMethod is accessible via phantom property T

type PaymentMethodType = typeof PaymentMethod.T;
// UnionVal<{Cash:..., Check:..., CreditCard:...}>

API and implementation details

If you log a union value to console you will see a plain object.

console.log(PaymentMethod.Check(15566909));
// {k:'Check', p0:15566909, p1: undefined, p2: undefined, a: 1}

This is because union values are objects under the hood. The k element is the key, p0 - p1 are passed in parameters and a is the number of parameters. I decided not to expose that through typings but I might reconsider that in the future. You cannot use it for redux actions, however you can safely use it for redux state.

Note that in version 2.0 it was a tuple. But benchmarks showed that object are more efficient (I have no idea why arrays cannot be jitted efficiently). You can find more details below

API

Use Union constructor to define the type

import { Union, of } from 'ts-union';

const U = Union({
  Simple: of(), // or of<void>(). no payload.
  SuperSimple: of(null), // static union value with no payload
  One: of<string>(), // one argument
  Const: of(3), // one constant argument that is baked in
  Two: of<string, number>(), // two arguments
  Three: of<string, number, boolean>(), // three
});

// generic version
const Option = Union((t) => ({
  None: of(null),
  Some: of(t), // Note: t is a value of the special type Generic
}));

// for static variant values you still have to provide a type
// because it needs to "remember" the type.
// Thus a function call, but it will always return the same object
const opt = Option.None<string>();

// But here type is inferred as number
const opt2 = Option.Some(5);

Let's take a closer look at of function

export interface Types {
  (unit: null): Of<[Unit]>;
  <T = void>(): Of<[T]>;
  (g: Generic): Of<[Generic]>;
  <T>(val: T): Const<T>;
  <T1, T2>(): Of<[T1, T2]>;
  <T1, T2, T3>(): Of<[T1, T2, T3]>;
}
declare const of: Types;

the actual implementation is pretty simple:

export const of: Types = ((val: any) => val) as any;

We just capture the constant and don't really care about the rest. Typescript will guide us to provide proper number of args for each case.

match accepts either a full set of props or a subset with a default case.

// typedef for match function. Note there is a curried version
export type MatchFunc<Record> = {
  <Result>(cases: MatchCases<Record, Result>): (
    val: UnionVal<Record>
  ) => Result;
  <Result>(val: UnionVal<Record>, cases: MatchCases<Record, Result>): Result;
};

if either accepts a function that will be invoked (with a match) and/or else case.

// typedef for if case for one argument.
// Note it doesn't throw but can return undefined
{
    <R>(val: UnionVal<Rec>, f: (a: A) => R): R | undefined;
    <R>(val: UnionVal<Rec>, f: (a: A) => R, els: (v: UnionVal<Rec>) => R): R;
}

GenericValType is a type that helps with generic union values. It just replaces Generic token type with provided Type.

type GenericValType<Type, Val> = Val extends UnionValG<infer _Type, infer Rec>
  ? UnionValG<Type, Rec>
  : never;

// Example
import { Union, of, GenericValType } from 'ts-union';
const Maybe = Union((t) => ({ Nothing: of(), Just: of(t) }));
type MaybeVal<T> = GenericValType<T, typeof Maybe.T>;

That's the whole API.

Benchmarks

You can find a more details here. Both unionize and ts-union are 1.2x -2x (ish?) times slower than handwritten discriminated unions: aka {tag: 'num', n: number} | {tag: 'str', s: string}. But the good news is that you don't have to write the boilerplate yourself, and it is still blazing fast!

Breaking changes from 2.1.1 -> 2.2.0

There should be no public breaking changes, but I changed the underlying data structure (again!? and again!?) to be {k: string, p0: any, p1: any, p2: any, a: number}, where k is a case name like "CreditCard", p0-p2 passed in parameters and a is how many parameters were passed in. So if you stored the values somewhere (localStorage?) then please migrate accordingly.

const oldShape = { k: 'CreditCard', p: ['Visa', '1111-566-...'] };
const newShape = {
  k: 'CreditCard',
  p0: 'Visa',
  p1: '1111-566-...',
  p2: undefined,
  a: 2,
};

motivation for this is potential perf wins avoiding dealing with (...args) => {...}. The current approach should be more friendly for JIT compilers (arguments and ...args are hard to optimize). That kinda aligns with my local perf results:

old shape

Creation
    baseline: 8.39 ms
    unionize: 17.32 ms
    ts-union: 11.10 ms

Matching with inline object
    baseline: 1.97 ms
    unionize: 5.96 ms
    ts-union: 7.32 ms

Matching with preallocated function
    baseline: 2.20 ms
    unionize: 4.21 ms
    ts-union: 4.52 ms

Mapping
    baseline: 2.02 ms
    unionize: 2.98 ms
    ts-union: 1.69 ms

new shape

Creation
    baseline: 6.90 ms
    unionize: 15.62 ms
    ts-union: 6.38 ms

Matching with inline object
    baseline: 2.33 ms
    unionize: 6.26 ms
    ts-union: 5.19 ms

Matching with preallocated function
    baseline: 1.67 ms
    unionize: 4.44 ms
    ts-union: 3.88 ms

Mapping
    baseline: 1.96 ms
    unionize: 2.93 ms
    ts-union: 1.39 ms

Breaking changes from 2.0.1 -> 2.1

There should be no public breaking changes, but I changed the underlying data structure (again!?) to be {k: string, p: any[]}, where k is a case name like "CreditCard" and p is a payload array. So if you stored the values somewhere (localStorage?) then please migrate accordingly.

The motivation for it that I finally tried to benchmark the performance of the library. Arrays were 1.5x - 2x slower than plain objects :(

const oldShape = ['CreditCard', ['Visa', '1111-566-...']];

// and yes this is faster. Blame V8.
const newShape = { k: 'CreditCard', p: ['Visa', '1111-566-...'] };

Breaking changes from 1.2 -> 2.0

There should be no breaking changes, but I completely rewrote the types that drive public api. So if you for some reasons used them pls look into d.ts file for a replacement.

Breaking changes from 1.1 -> 1.2

  • t function to define shapes is renamed to of.
  • There is a different underlying data structure. So if you persisted the values somewhere it wouldn't be compatible with the new version.

The actual change is pretty simple:

type OldShape = [string, ...payload[any]];
// Note: no nesting
const oldShape = ['CreditCard', 'Visa', '1111-566-...'];

type NewShape = [string, payload[any]];
// Note: captured payload is nested
const newShape = ['CreditCard', ['Visa', '1111-566-...']];

That reduces allocations and opens up possibility for future API extensions. Such as:

// namespaces to avoid collisions.
const withNamespace = ['CreditCard', ['Visa', '1111-566-...'], 'MyNamespace'];

Ts Union

ADT sum type in typescript

Ts Union Info

⭐ Stars 63
🔗 Source Code github.com
🕒 Last Update 2 months ago
🕒 Created 4 years ago
🐞 Open Issues 12
➗ Star-Issue Ratio 5
😎 Author twop