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
- Host: GitHub
- URL: https://github.com/twop/ts-union
- Owner: twop
- License: mit
- Created: 2018-04-07T23:44:45.000Z (about 8 years ago)
- Default Branch: master
- Last Pushed: 2023-01-06T01:37:14.000Z (over 3 years ago)
- Last Synced: 2025-10-12T03:15:32.345Z (9 months ago)
- Topics: adt, sum-types, typescript
- Language: TypeScript
- Homepage:
- Size: 1.35 MB
- Stars: 70
- Watchers: 0
- Forks: 2
- Open Issues: 15
-
Metadata Files:
- Readme: README.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE
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'];
```