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: uses features from typescript 3.0 (such as unknown
type)
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'
);
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 and the p
is the payload array. 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).
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.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-...'] };
Last but not least: no CJS only ESM for distribution.
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'];