0.1.3 • Published 4 years ago

@lato/sum-types v0.1.3

Weekly downloads
-
License
MIT
Repository
github
Last release
4 years ago

@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
}
0.1.3

4 years ago

0.1.2

6 years ago

0.1.1

6 years ago

0.1.0

6 years ago

0.0.3

6 years ago