An open API service indexing awesome lists of open source software.

https://github.com/twop/ts-union

ADT sum type in typescript
https://github.com/twop/ts-union

adt sum-types typescript

Last synced: 8 months ago
JSON representation

ADT sum type in typescript

Awesome Lists containing this project

README

          

# ts-union

A tiny library for algebraic sum types in typescript. Inspired by [unionize](https://github.com/pelotom/unionize) and [F# discriminated-unions](https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/discriminated-unions) (and other ML languages)

## Installation

```
npm add ts-union
```

NOTE: Distrubuted as modern javascript (es2018) library.

## Usage

### Define

```typescript
import { Union, of } from 'ts-union';

const PaymentMethod = Union({
Check: of(),
CreditCard: of(),
Cash: of(null), // means that this variant has no payload
});

type CheckNumber = number;
type CardType = 'MasterCard' | 'Visa';
type CardNumber = string;
```

### Construct a union value

```typescript
// 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`

```typescript
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.

```typescript
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)

```typescript
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.

```typescript
// 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:

```ts
const State = Union({
Loading: of(null),
Loaded: of(),
Err: of(),
});

const Ev = Union({
ErrorHappened: of(),
DataFetched: of(),
});

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:

```ts
import { Union, of } from 'ts-union';

const State = Union({
Loading: of(null),
Loaded: of(),
Err: of(),
});

const Ev = Union({
ErrorHappened: of(),
DataFetched: of(),
});

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 :)

```ts
const logLoadingTransition = State.matchWith(Ev, {
Loading: {
ErrorHappened: (_, err) => 'Oops, error happened: ' + err,
DataFetched: (_, data) => 'Data loaded with: ' + data.toString(),
},
default: () => '',
});
```

#### Caveats

1. Doesn't support generic version (yet?)
2. Doesn't work with unions that have more than 1 arguments in variants. E.g. `of()` will give an incomprehensible type error.
3. You cannot pass additional data to the update function. I'm tinkering about something like this for the future releases:

```ts
const transition = State.matchWith(Ev, {...}, of());
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()`;

```ts
const Nope = Union({
Old: of(), // 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:

```ts
// 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();
```

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

```typescript
// 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`):

```typescript
const ReqResult = Union((data) => ({
Pending: of(null),
Ok: of(data),
Err: of(),
}));

// res is inferred as UnionValG
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`:

```typescript
const { Nothing, Just } = Maybe;

// GenericValType is a helper that allows you to substitute Generic token type.
type MaybeVal = GenericValType;

const map = (val: MaybeVal, f: (a: A) => B) =>
Maybe.match(val, {
Just: (v) => Just(f(v)),
Nothing: () => Nothing(), // note that we have to explicitly provide B type here
});

const bind = (val: MaybeVal, f: (a: A) => MaybeVal) =>
Maybe.if.Just(
val,
(a) => f(a),
(n) => (n as unknown) as MaybeVal
);

map(Just('a'), (s) => s.length); // -> Just(1)
bind(Just(100), (n) => Just(n.toString())); // -> Just('100')

map(Nothing(), (s) => s.length); // -> Nothing
```

And if you want to **extend** `Maybe` with these functions:

```typescript
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.

```typescript
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`

```typescript
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.

```typescript
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](https://github.com/twop/ts-union/tree/master/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

```typescript
import { Union, of } from 'ts-union';

const U = Union({
Simple: of(), // or of(). no payload.
SuperSimple: of(null), // static union value with no payload
One: of(), // one argument
Const: of(3), // one constant argument that is baked in
Two: of(), // two arguments
Three: of(), // 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();

// But here type is inferred as number
const opt2 = Option.Some(5);
```

Let's take a closer look at `of` function

```typescript
export interface Types {
(unit: null): Of<[Unit]>;
(): Of<[T]>;
(g: Generic): Of<[Generic]>;
(val: T): Const;
(): Of<[T1, T2]>;
(): Of<[T1, T2, T3]>;
}
declare const of: Types;
```

the actual implementation is pretty simple:

```typescript
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.

```typescript
// typedef for match function. Note there is a curried version
export type MatchFunc = {
(cases: MatchCases): (
val: UnionVal
) => Result;
(val: UnionVal, cases: MatchCases): Result;
};
```

`if` either accepts a function that will be invoked (with a match) and/or else case.

```typescript
// typedef for if case for one argument.
// Note it doesn't throw but can return undefined
{
(val: UnionVal, f: (a: A) => R): R | undefined;
(val: UnionVal, f: (a: A) => R, els: (v: UnionVal) => R): R;
}
```

`GenericValType` is a type that helps with generic union values. It just replaces `Generic` token type with provided `Type`.

```typescript
type GenericValType = Val extends UnionValG
? UnionValG
: never;

// Example
import { Union, of, GenericValType } from 'ts-union';
const Maybe = Union((t) => ({ Nothing: of(), Just: of(t) }));
type MaybeVal = GenericValType;
```

That's the whole API.

### Benchmarks

You can find a more details [here](https://github.com/twop/ts-union/tree/master/benchmarks). 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.

```ts
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 :(

```ts
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 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:

```typescript
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:

```typescript
// namespaces to avoid collisions.
const withNamespace = ['CreditCard', ['Visa', '1111-566-...'], 'MyNamespace'];
```