ts-union
Tiny library (<1Kb unminified & unzipped) 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({
Cash: of<void>(),
Check: of<CheckNumber>(),
CreditCard: of<CardType, CardNumber>()
});
type CheckNumber = number;
type CardType = 'MasterCard' | 'Visa';
type CardNumber = string;
Construct
const cash = PaymentMethod.Cash();
const check = PaymentMethod.Check(15566909);
const card = PaymentMethod.CreditCard('Visa', '1111-566-...');
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, then 'undefined' type will be removed from the result.
const str = PaymentMethod.if.Check(
cash,
n => `check num: ${n.toString()}`,
_v => 'not check'
);
Generic version
const Maybe = Union(val => ({
Nothing: of<void>(),
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<void>(),
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
Atm types of union values are opaque. That allows me 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 will try to log a union value you will see just an array.
console.log(PaymentMethod.Check(15566909));
All union values are arrays. The first element is the case key and the second is 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.
Api
Use Union
to define shape
import { Union, of } from 'ts-union';
const U = Union({
Simple: of(),
One: of<string>(),
Const: of(3),
Two: of<string, number>(),
Three: of<string, number, boolean>()
});
const Option = Union(t => ({
None: of<void>(),
Some: of(t)
}));
Let's take a closer look at of
function
interface Types {
<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>;
And that is the whole api.
Technically breaking changes going 1.2 -> 2.0
There should be no breaking changes but I removed some exported types to reduce api surface.
export {
Union,
of,
GenericValType,
UnionObj,
GenericUnionObj
};
Breaking changes going 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 allows to reduce allocations and it opens up future api extensibility. Such as:
const withNamespace = ['CreditCard', ['Visa', '1111-566-...'], 'MyNamespace'];