@lato/sum-types v0.1.3
@lato/sum-types
Another sum type library in javascript. This one uses
function application for pattern matching. Unlike other libraries
you don't have to declare your types before usage. Sum
is to types
created with similar libraries as object literal is to classes.
Basic usage
import { Sum } from '@lato/sum-types';
const area = shape => shape({
circle: radius =>
Math.PI * radius * radius,
square: side =>
side * side
});
console.log("Area of a square with a side length 2 is", area(Sum.square(2)));
Description
There are plenty of sum type libraries. This one tries to be as bare bones and easy to use as possible, yet without any limitations. If you don't mind some minimal clutter, you should consider looking at other libraries. They may be more feature rich and mature:
Single type vs. multiple types
Main thing that differs from similar libraries is that
there is only one "open" sum type Sum
, i.e.
Object.getPrototypeOf(Sum.square(8)) === Object.getPrototypeOf(Sum.monday(true))
Sum
is not to be extended with new methods. I find very little benefit
in using classes as opposed to object literals in js. There is no
typechecker that would warn you if you start mixing apples with cars and
there's ever broken this
. Same argument applies to sum types.
Not an ADT
This is not a full ADT library. It provides only the "sum" part in ADT, assuming the "product" part is a regular js object. As a consequence, if you were to add a triangle case to the example above, it would be:
// ...
triangle: ({ a, h }) => a * h / 2,
// ...
area(Sum.triangle({ a: 2, h: 3 }));
instead of more pleasant:
// ...
triangle: (a, h) => a * h / 2,
// ...
area(Sum.triangle(2, 3));
While the second style is less verbose, first style is a much better building block - generic code does not have to deal with argument list, because there is only one argument. It is easier to compose.
Basic types
There are three basic types, that are not built on top of Sum
.
Unit
Most boring type that is inhabited by only one value:
import { unit } from '@lato/sum-types';
const boring = _ => unit;
It makes little sense to use it in js, but maybe someone can come up with a genuine use case.
Maybe
Standard type that can express a lack of a value:
import { nothing, just } from '@lato/sum-types';
const div5 = x => (x === 0) ? nothing : just(5 / x);
const result = div5(0);
result(
_ => console.log("division by 0!"),
x => console.log("got", x)
);
Comes with .map
and .then
methods.
Either
An alternative of two types. With .then
method,
usually used to short-circuit on errors:
import { left, right } from '@lato/sum-types';
const process = x =>
fetch(x)
.then(parse)
.then(object =>
("prop" in object) ?
left("no prop!") :
right(object.prop)
);
process(task)(
err => console.log("failed with", err),
val => console.log("succeeded with", val)
);
.map
is also available.
Enum
Very often values of Sum
type don't need to contain anything
meaningful - it's only the tag that matters. You can use
Enum
for that:
import { Enum } from '@lato/sum-types';
const colors = [ Enum.red, Enum.green, Enum.blue ];
const shiftColor = c => c({
red: _ => Enum.green,
green: _ => Enum.blue,
blue: _ => Enum.red
});
Enum.prop
is simply Sum.prop(unit)
.
Note that _ =>
is still needed, because of the order of
evaluation. Body of the matching case is to be evaluated
only on a successful match, not before matching, for all cases.
Otherwise branch
Matching sums with many similar cases could be very repetitive.
To avoid this otherwise
can be used:
import { Enum, otherwise } from '@lato/sum-types';
const launchOnWednesday = day => day({
wednesday: _ => launchMissiles(),
[otherwise]: _ => chill()
});
launchOnWednesday(Enum.saturday);
If a matching object doesn't have a branch for a sum that requires it,
otherwise
branch will be tried. It that fails, an Error
is thrown:
import { Enum } from '@lato/sum-types';
const color = Enum.red;
color({
blue: _ => 17,
green: _ => 18
});
// Error {
// message: 'Match error. Case "red" not found in { "blue", "green" }.',
// }
Gotcha
otherwise
is a Symbol, so if you wrote:
day({
monday: f,
otherwise: _ => g()
});
probably you've meant:
day({
monday: f,
[otherwise]: _ => g()
});
Any other Symbol would be also a valid tag:
const red = Symbol();
const color = Enum[red];
color({
[red]: _ => f()
// ...
});
There is only one invalid tag that misbehaves: prototype
.
If compiled to ES5, Sum
and Enum
become functions.
Any function in ES5 could be a (js) constructor, so it is given a prototype
field. cacheable-proxy
would remove it like length
, name
and other function
fields, but unfortunately prototype
is not configurable. Hence
Sum.prototype
cannot point to a valid (sum-types
) constructor.
Sum[otherwise](val)
doesn't make sense. If you use it, you are
asking for trouble.
The argument
Matching in otherwise
branch doesn't replace inner value with a dummy
unit
. It gets passed the real one. Such behaviour is exploitable and
may lead to bugs:
// is x always a `Number`? What about other branches?
const unwrapAndIncrement = s => s({
[otherwise]: x => x + 1
});
The inner value is passed, because there exist expressions that shouldn't be rejected as ill-typed:
// extract : Sum{ red: Number, green: Number } -> Number
const extract = s => s({
[otherwise]: x => x
});
// seventeenUnlessRed : Sum{ red: Number, green: Number } -> Number
const seventeenUnlessRed = s => s({
red: x => x,
[otherwise]: _ => 17
});
// Pair = { fst: Number, snd: Number }
// swapUnlessRed :
// Sum{ red: Pair, green: Pair, blue: Pair } ->
// Sum{ red: Pair, green: Pair, blue: Pair }
const swapUnlessRed = s => s({
red: x => Sum.red(x),
[otherwise]: (x, ctor) => ctor({ fst: x.snd, snd: x.fst }) // see below
});
Obtaining constructors
There is a subtle shortcoming that need to addressed. Constructors
(not those in js sense) of a "closed" sum type are always known
at compile time. Constructors of Sum
are not. Consider receiving
a sum value from third party library. You know the library uses
at least one constructor, because received value was created
with one. There should be a way to access it and it is
solved by passing said constructor as the second argument in
otherwise
branch.
const someCtor = alienObject({
[otherwise]: (_, ctor) => ctor
});
Since it doesn't add any more expressivness, other branches also receive their constructors.
Helpers
maybe
, either
, sum
Replace s => s(...
pattern with maybe
, either
or sum
:
import { maybe, either, sum } from '@lato/sum-types';
const onSumOld = s => s({ p: f, q: g });
const onSumNew = sum({ p: f, q: g }); // `sum` also works for `Enum`
const onMaybeOld = m => m(nothingCase, justCase);
const onMaybeNew = maybe(nothingCase, justCase);
const onEitherOld = e => e(leftCase, rightCase);
const onEitherNew = either(leftCase, rightCase);
dropTags
dropTags : Sum{ p: a, q: a, r: a, ... } -> a
cast
A hack for debugging purposes. Extracts inner value,
constructor, and a tag. It passes a proxy object to a sum
value to obtain a tag, so it may be slow. Default
.toString
method is based on cast
.
const { tag, value, ctor } = Sum.apple(17);
// value === 17
// tag === "apple"
// ctor(value) is the same as Sum.apple(17)
Performance
Proxy cost
Sum
and Enum
are objects that have proxies down the prototype
chain. If you are concerned about slow lookups on them, there are
few ways to mitigate any potential performance impact.
Consider the case where some constructor is being fetch frequently:
for(...) {
expr(Sum.apple(value))
}
Moving the lookup out of scope
const appleCtor = Sum.apple;
for(...) {
expr(appleCtor(value))
}
Storing constructors in Sum
Sum.apple = Sum.apple;
// for Enum
// Enum.apple = Enum.apple
for(...) {
expr(Sum.apple(value))
}
In general, it is not a good idea to write in external libraries' objects.
However writing to Sum
or Enum
is utterly predictable, so it shouldn't
lead to a name clash or changed behavior. Neither object contain any
properties of its own (except prototype
in ES5). Only thing changed
is performance.
Like first method, this code still needs additional clutter before using sum values.
Calling Sum
Sum
and Enum
are also functions that accept properties as argumets.
That way lookup on proxy can be avoided.
for(...) {
expr(Sum("apple")(value))
}
I don't care / unbounded cache
Library exports two more functions: setConstructorCaching
and
clearConstructorCache
. After a call to setConstructorCaching(true)
every new constructor will be stored in Sum
/Enum
as described in
the second method.
setConstructorCaching(true);
for(...) {
expr(Sum.apple(value)) // first lookup slower
}