ts-union
v2.3.0
Published
ADT (sum type) in typescript inspired by ML language family
Downloads
3,245
Readme
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
- 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>(), // 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 toof
.- 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'];