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),
});
type CheckNumber = number;
type CardType = 'MasterCard' | 'Visa';
type CardNumber = string;
Construct a union value
const check = PaymentMethod.Check(15566909);
const card = PaymentMethod.CreditCard('Visa', '1111-566-...');
const cash = PaymentMethod.Cash;
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',
});
const str = toStr(card);
if
(aka simplified match)
const str = PaymentMethod.if.Cash(cash, () => 'yep');
You can provide else case as well, in that case 'undefined' type will be removed from the result.
const str = PaymentMethod.if.Check(
cash,
(n) => `check num: ${n.toString()}`,
(_v) => '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) =>
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,
});
const newState = transition(Loading, Ev.ErrorHappened('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
- Doesn't support generic version (yet?)
- Doesn't work with unions that have more than 1 arguments in variants. E.g.
of<string, number>()
will give an incomprehensible type error. - 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>(),
New: of(null),
});
const nope = Nope.New;
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:
const Option = Union((t) => ({
None: of(null),
Some: of(t),
}));
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
const Maybe = Union((val) => ({
Nothing: of(null),
Just: of(val),
}));
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>(),
}));
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}`,
});
Let's try to build map
and bind
functions for Maybe
:
const { Nothing, Just } = Maybe;
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>(),
});
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);
bind(Just(100), (n) => Just(n.toString()));
map(Nothing<string>(), (s) => s.length);
And if you want to extend Maybe
with these functions:
const TempMaybe = Union(val => ({
Nothing: of(),
Just: of(val)
}));
const map = .....
const bind = .....
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;
The UnionVal<...>
type for PaymentMethod
is accessible via phantom property T
type PaymentMethodType = typeof PaymentMethod.T;
API and implementation details
If you log a union value to console you will see a plain object.
console.log(PaymentMethod.Check(15566909));
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(),
SuperSimple: of(null),
One: of<string>(),
Const: of(3),
Two: of<string, number>(),
Three: of<string, number, boolean>(),
});
const Option = Union((t) => ({
None: of(null),
Some: of(t),
}));
const opt = Option.None<string>();
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.
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.
{
<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;
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-...']];
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]];
const oldShape = ['CreditCard', 'Visa', '1111-566-...'];
type NewShape = [string, payload[any]];
const newShape = ['CreditCard', ['Visa', '1111-566-...']];
That reduces allocations and opens up possibility for future API extensions. Such as:
const withNamespace = ['CreditCard', ['Visa', '1111-566-...'], 'MyNamespace'];