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
; ; ;;;
Construct a union value
// Check is a function that accepts a check number; // CreditCard is a function that accepts two arguments (CardType, CardNumber); // Cash is just a value; // or destructure it to simplify construction :);;
match
;
Also supports deferred (curried) matching and default
case.
; ; // "not cash"
if
(aka simplified match)
; // "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; // str === 'not check'
matchWith
EXPERIMENTAL 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:
; ; ; ;
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:
; ; ; ; ; // usage; // <-- 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 :)
;
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:
;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>()
;
; // Note that New is a value not a function; // here Old is a function;
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; // we need to provide a type for the Option to "remember" it.;
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;
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
):
; // res is inferred as UnionValG<string, ...>; ; // 'Ok, this is awesome!'
Let's try to build map
and bind
functions for Maybe
:
; // GenericValType is a helper that allows you to substitute Generic token type.; ; ; mapJust'a',s.length; // -> Just(1)bindJust100,Justn.toString; // -> Just('100') mapNothing,s.length; // -> Nothing
And if you want to extend Maybe
with these functions:
; // TempMaybe is just an object, so this is perfectly legit;
Type of resulted objects
Types of union values are opaque. That makes it possible to experiment with different underlying data structures.
;// UnionVal<{Cash:..., Check:..., CreditCard:...}>// and it is the same for card and check
The UnionVal<...>
type for PaymentMethod
is accessible via phantom property T
;// UnionVal<{Cash:..., Check:..., CreditCard:...}>
API and implementation details
If you log a union value to console you will see a plain object.
console.logPaymentMethod.Check15566909;// {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
; ; // generic version; // 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; // But here type is inferred as number;
Let's take a closer look at of
function
declare ;
the actual implementation is pretty simple:
;
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;
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
GenericValType
is a type that helps with generic union values. It just replaces Generic
token type with provided Type
.
; // Example;;;
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.
;;
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 :(
; // and yes this is faster. Blame V8.;
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:
;// Note: no nesting; ;// Note: captured payload is nested;
That reduces allocations and opens up possibility for future API extensions. Such as:
// namespaces to avoid collisions.;