Skip to Content

Day 88: on fantasy, part 3

Posted on 4 mins read

This is part 3 of a series on Implementing a (kinda) fantasy land compliant Maybe type. For part 1, go here and part 2 is here

Last time, we finished our Semigroup instance on our Maybe implementation, and gave it also a Monoid instance. Today, we’ll focus on another algebra, namely the Functor.

Map all the things

The Functor specification contains just one method: map, which looks like this:

map :: Functor f => f a ~> (a -> b) -> f b

And what that means, is that it’s a method on every Functor instance that takes one argument which must be a function and returns a Functor of the same type. And if that sounds familiar, it’s because javascript’s Array implements a map function that works like this!

In our case, we have two possibilities: either we map on a Nothing or on a Just with something inside. For the first case, my assumption is that, mapping over Nothing should yield also Nothing. So, our first test and the code that makes it pass:

it("should return Nothing if called on a Nothing", () => {
  const expected = Nothing().toString();
  const actual = Nothing()
    .map(Math.sqrt)
    .toString();
  expect(actual).toBe(expected);
});

const Nothing = value => ({
  value: () => Nothing(),
  isNothing: () => true,
  isJust: () => false,
  concat: other => other,
  map: fn => Nothing(),
  toString: () => "Nothing",
  inspect: () => "Nothing",
  instances: ["Semigroup", "Monoid"]
});

So Nothing().map(fn) will always return Nothing. For our second case, we need to apply the function to what we have inside, and then re-wrap it in our Just. A first test (and implementation) would be:

it("should return the result of applying the function over the value if it's not an object or array", () => {
  const expected = Just(3).toString();
  const actual = Just(9)
    .map(Math.sqrt)
    .toString();
  expect(actual).toBe(expected);
});

const Just = value => ({
  value: () => value,
  isNothing: () => false,
  isJust: () => true,
  concat: other => {
    if (other.isNothing()) return Just(value);
    return Just(fantasyConcat(value, other.value()));
  },
  map: fn => return Just(fn (value)),
  toString: () => `Just(${value})`,
  inspect: () => `Just(${value})`,
  instances: ["Semigroup", "Monoid"]
});

That makes the test pass. But remember how we said Array has a map function? We could take advantage of that, since we know already how Arrays are supposed to get mapped over, or at least what a very canonical implementation of mapping over a List like structure looks like. And we can extend such an implementation to also work on Objects if we like. So, we abstract our mapping behaviour in a helper function and use it inside every instance of Functor we write:

const fantasyMap = (fn, value) => {
  if (Array.isArray(value)) return value.map(fn);
  if (typeof value === "object")
    return Object.keys(value).reduce((acc, cur) => {
      acc[cur] = fn(value[cur]);
      return acc;
    }, {});
  return fn(value);
};

// And our Just turns into: 

const Just = value => ({
  value: () => value,
  isNothing: () => false,
  isJust: () => true,
  concat: other => {
    if (other.isNothing()) return Just(value);
    return Just(fantasyConcat(value, other.value()));
  },
  map: fn => Just(fantasyMap(fn, value)),
  toString: () => `Just(${value})`,
  inspect: () => `Just(${value})`,
  instances: ["Semigroup", "Monoid"]
});

Now, we either map over arrays, objects or just any old plain value that isn’t either of those.

There’s just one more step to complete our instance of Functor, and that is testing the law. Functor’s map fulfils 2 laws:

  • u.map(a => a) is equivalent to u (identity)
  • u.map(x => f(g(x))) is equivalent to u.map(g).map(f) (composition)

And here’s how we write our tests for them:

const jsc = require("jsverify");

// Laws
const identity = x => x.map(y => y).toString() === x.toString();

const composition = (f, g, x) =>
  x.map(compose(f, g)).toString() ===
  x
    .map(g)
    .map(f)
    .toString();

// Note: Better way to write arbitraries for our algebras. I think. 

const maybeArb = jsc.string.smap(Just, x => x.value(), y => y.toString());

// Tests
it("should fulfil the identity property", () => {
  expect(jsc.checkForall(maybeArb, identity)).toBe(true);
});

it("should fulfil the composition property", () => {
  expect(jsc.checkForall(jsc.fn(jsc.string), jsc.fn(jsc.string), maybeArb, composition)).toBe(
    true
  );
});

Note: I found a better way to write arbitraries for our algebras, yay! How it works is something I don’t quite comprehend yet. But as soon as I do, I’ll write about it.

And with that, we’re done! Our Maybe implementation now has a Functor instance! We’re just getting started though, we still have 4 more algebra instances to give our Maybe so it doesn’t get picked on by the other Maybes out there.

  • Semigroup ✅
  • Monoid ✅
  • Functor ✅
  • Coming up next: Apply & Applicative!
comments powered by Disqus