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
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 to log a union value all you see is an array.
console.log(PaymentMethod.Check(15566909));
This is because union values are arrays under the hood. The first element is the 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
constructor to define the type
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>;
That's the whole API.
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'];