From function to contravariant functor

Posted on June 25, 2020 by Bradley DeJong

Where we’ve been

If you haven’t done so already, be sure to read the introductory post and the source code that we left off with last time.

Where we’re going

If you recall, I promised we would make our view functions more composable. Let’s take a look at a small subset of the views:

const renderClicky = (state, dispatch) => html`
  <div>
    You've clicked ${state.clicks} times
  </div>
  <button onclick=${() => dispatch("clicked")}>
    Click Me
  </button>
`;

// ...other views

const content = (state, dispatch) => html`
  <div class="content">
    ${renderRefresh(state, dispatch)}
    ${renderDecorations()}
    ${renderClicky(state, dispatch)}
  </div>
`;

Notice that the content view dutifully passes along state and dispatch to two out of the three sub-views it contains. Surely we can make that better.

A second point: renderClicky doesn’t need access to the entire application state. It just cares about the clicks property. It’s irresponsible to give that view access to all of our secret data! In addition, what if we move clicks to a different part of the state tree?

These two problems will take a few steps to solve, but fear not! Smarter people than I have told me how to solve them.

Step 1: Not everything needs everything

Let’s work on a way to make our views work with only the data they need, rather than the entire application state. Not only does this reduce temptation to build giant components, but it isolates where the data resides from how to display that data.

Reduce the amount of state

Let’s refactor renderClicky.

const renderClicky = (clicks, dispatch) => html`
  <div>
    You've clicked ${clicks} times
  </div>
  <button onclick=${() => dispatch("clicked")}>
    Click Me
  </button>
`;

All we did was change state to clicks in the function parameters, and state.clicks to clicks in the HTML body. Now we have to refactor the content view to only pass clicks.

const content = (state, dispatch) => html`
  <div class="content">
    ${renderRefresh(state, dispatch)}
    ${renderDecorations()}
    ${renderClicky(state.clicks, dispatch)}
  </div>
`;

Our bottom-most view, renderClicky, now doesn’t require the entire application state! This is great! But what if we wanted a more reusable way of mapping global state to smaller bits?

Let’s introduce a new type, View, to help us do that.

const View = render => ({
  render
});

A View object just takes a render function and returns an object with a property render. This is silly, you say? I agree, for now. Let’s see what our code looks like now.

const renderClicky = View((clicks, dispatch) => html`
  <div>
    You've clicked ${clicks} times
  </div>
  <button onclick=${() => dispatch("clicked")}>
    Click Me
  </button>
`);

const content = (state, dispatch) => html`
  <div class="content">
    ${renderRefresh(state, dispatch)}
    ${renderDecorations()}
    ${renderClicky.render(state.clicks, dispatch)}
  </div>
`;

Eew. We had to say renderClicky.render(state.clicks, dispatch), which is a lot of typing. Why did we do this? Well, by introducing lazy evaluation - render won’t be called until we call it - we give ourselves opportunity to do some trickery with the View itself.

I’m going to define a function, withAdapter, that does nothing for the moment. Hang with me.

const View = render => ({
  withAdapter: () => View(render)
  render
});

Now we can call

renderClicky
  .withAdapter()
  .withAdapter()
  .withAdapter()

infinitely and it will always return a new view with the exact same render function. Before you give up on me entirely, let’s introduce one argument to withAdapter.

const View = render => ({
  withAdapter: (adapterFn) => View((state, dispatch) =>
    render(adapterFn(state), dispatch)),
  render
});

What have we here? We can now supply an adapter function that runs on the input state before the view gets rendered. Can you feel the power? We can now isolate the renderClicky.render(state.clicks, dispatch) code like so:

const content = (state, dispatch) => html`
  <div class="content">
    ${renderRefresh(state, dispatch)}
    ${renderDecorations()}
    ${renderClicky.withAdapter(s => s.clicks).render(state, dispatch)}
  </div>

Now this may seem like a lot of extra boilerplate, but let me point out one thing: our renderRefresh view and our renderClicky view (after .withAdapter) now have very similar type signatures (state, dispatch) => HTML and View((state, dispatch) => HTML). This is very important, trust me.

Refactoring renderRefresh

Why? Didn’t I just say it was important that our sibling views had the same type signature? Sure, but renderRefresh still greedily takes the entire global state instead of just state.lastUpdated, which is the only piece of information it needs.

const renderRefresh = View((lastUpdated, dispatch) => html`
    <div>
      Last updated at ${lastUpdated.toLocaleString()}.
    </div>
    <button onclick=${() => dispatch("update")}>
      Click To Update
    </button>
  `);

const renderDecorations = View((state, dispatch) => html`
    <div class="decoration">
      insert decoration here
    </div>
    <span class="decoration"> 🦄🦄🦄🦄🦄🦄 </span>
  `);


//...other views


const content = (state, dispatch) => html`
      <div class="content">
        ${renderRefresh.withAdapter(s => s.lastUpdated).render(state, dispatch)}
        ${renderDecorations.render(state, dispatch)}
        ${renderClicky.withAdapter(s => s.clicks).render(state, dispatch)}
      </div>
`;

If you don’t see how this refactor worked, take a minute and review the previous section. You’ll get it.

Note: renderDecorations doesn’t require state nor dispatch. We could call renderDecorations.withAdapter(s => "HAHAHAHAHAHA") and it wouldn’t matter. But the code runs all the same.

Now we have three sibling View functions that all have the same signature.

Scary math

By using withAdapter we’ve now defined what’s called a Contravariant Functor. I’ll leave a write up of this idea for another day, but suffice it to say I believe strongly in calling things by their proper name. As developers, we google everything, so let’s take advantage of years of math in our google searches, and rename withAdapter to contramap.

const View = render => ({
  contramap: (adapterFn) => View((state, dispatch) =>
    render(adapterFn(state), dispatch)),
  render
});

Renaming all instances of withAdapter should be straightforward as an exercise.

Step 2: concat to the rescue

With sibling View functions all with the same type, there’s got to be some way to get rid of the .render(state, dispatch) boilerplate, right? Let’s talk about combining things.

Semigroups

Very loosely defined, a Semigroup is simply something of some Type that knows how to combine with another thing of the same Type. We use them all the time.

[1,2,3].concat([4,5,6]); // [1,2,3,4,5,6]

Hopefully the above code makes sense. Now, let’s use our imaginations a bit:

1 + 2; // 3
1 * 2; // 2

What if I told you those two operations were both eerily similar to concat? In this case, the operator * or + acts as the .concat method. Let’s move it to a type-based implementation to make it more clear.

const Sum = number => ({
  number,
  concat: otherSum => Sum(number + otherSum.number)
});

const Product = number => ({
  number,
  concat: otherProduct => Product(number * otherSum.number)
});

We’ve just defined two semigroups; numbers form the Semigroup Sum over addition and the Semigroup Product over multiplication.

Many operators can be viewed as a Semigroup with enough imagination.

Back to View

How can we make two View functions concat -able? One thing is for sure; we have to concat before calling .render, otherwise they’re not of the type View anymore.

const View = render => ({
  contramap: (adapterFn) => View((state, dispatch) => render(adapterFn(state), dispatch)),
  concat: otherView => undefined // ????
  render
});

What can we return from concat? Semigroups must be closed when concatting. That is, v1.concat(v2) has to return View, just as Sum(1).concat(Sum(2)) returns a Sum, Sum(3).

One thing we didn’t cover yet is that View secretly has type parameters. That is, we assume the inputs to two View function that are combined accept the same input state. Concretly, the type signature for concat within View is concat :: View a -> View a -> View a (if this type signature looks like the gibberish of a toddler, don’t worry! Check out Brian Lonsdorf’s excellent book or ask me for help). The type parameters must match. Armed with this knowledge, what if we just created a new View that called both View functions?

const View = render => ({
  contramap: (adapterFn) => View((state, dispatch) => render(adapterFn(state), dispatch)),
  concat: otherView => View((state, dispatch) => html`
    ${render(state, dispatch)}
    ${otherView.render(state, dispatch)}
  `),
  render
});

We simply create a view (waiting for state and dispatch, of course) that calls both views and puts them next to each other. Now we can combine views willy-nilly.

Combining views willy-nilly

const myAwesomeView = renderRefresh.contramap(s => s.lastUpdated)
  .concat(renderDecorations)
  .concat(renderClicky.contramap(s => s.clicks));

const content = (state, dispatch) => html`
  <div class="content">
    ${myAwesomeView.render(state, dispatch)}
  </div>
`;

Let that sink in. We have a way of combining View functions, as long as they take the same inputs.

Reducing boilerplate

Notice that we chain a bunch of `.concat` methods. Seems like something we can improve.

Let’s pause quick to write an `empty` view.

View.empty = View(() => html``);

This is a neat little identity. This means that View.empty.concat(anythingOtherView) is equivalent to anyOtherView. Similarly anyOtherView.concat(View.empty) is equivalent to anyOtherView.

This gives us a great seed for a reduce.

const myAwesomeView = [
    renderRefresh.contramap(s => s.lastUpdate),
    renderDecorations,
    renderClicky.contramap(s => s.clicks)
].reduce(
    (soFar, current) => soFar.concat(current),
    View.empty
);

Using ramda, we can make this even more terse without sacrificing readability.

const myAwesomeView = [
    renderRefresh.contramap(s => s.lastUpdate),
    renderDecorations,
    renderClicky.contramap(s => s.clicks)
].reduce(concat, View.empty);

To add a new child view, we can just insert it into the appropriate location in the array.

This post is too long

My fingers are tired, and I’m sure your eyes are, too. Please get a cup of coffee and think for a few minutes about the power contramap and concat give us. Then try to combine as many of our original view functions as you can, using View and the provided concat and contramap methods.

When you feel like you’ve done enough, or you want to cheat, take a look at the branch in github that implements the ideas in this article.

Next time…

We’ll look at removing that pesky dispatch argument.