Concepts: Chain

1. Motivation

So far I've covered Functor, Apply, and Applicative. Those are all useful interfaces, but they all share a consistent weakness. I cannot introspect and change the behavior of consumers based on the output of other consumers. This weakness is something I can take advantage of to write cleaner, more concise programs and is in general a good thing. Sometimes, however, I need to have consumer behavior depend on the value of other consumers. This is where the Chain interface becomes necessary.

I consider the following motivating example. Again I have the todo list as a theme. In this example I want to prioritize viewing some types of todos over others. I only show unfinished todos, then if there are no unfinished todos I show finished todos, and if there are no finished todos I show an example todo.

{chain-motivation-1 1}
{filter, 12:3}
const todos = (state, props) => state.todos;

const unfinishedTodos = map(
  todos => filter((todo) => !todo.finished, todos),
  todos
);

const finishedTodos = map(
  todos => filter((todo) => todo.finished, todos),
  todos
);

const templateTodo = { text: "Enter something to do!", finished: false };

const selector = (state, props) => {
  const u = unfinishedTodos(state, props);
  if (u.length > 0) {
    return u;
  }

  const f = finishedTodos(state, props);
  if (f.length > 0) {
    return f;
  }

  return templateTodo;
};

To combine these selectors manually is a little tedious and requires careful threading of the state and props arguments through the selector. With the chain operation the manual threading is eliminated and individual pieces of logic can be extracted.

{chain-motivation-1 1} +=
const orTemplate = (f) => (
  f.length > 1
    ? of(f)
    : of(templateTodo)
);

const orFinished = (u) => (
  u.length > 1
    ? of(u)
    : chain(finishedTodos, orTemplate)
);

const selector = chain(unfinishedTodos, orFinished);

2. Details

The Chain interface consists of a single method, bind, and some useful convenience methods that are implemented using bind. I'll cover the bind method first and the convenience methods later. The chain method in the interface is exactly the same as the bind method, but the argument order is switched. This lends for some more convenient usage in practice.

{types/Chain.js 2}
export type Chain<Static, In, OutA, OutB> = {
  bind: (OutA => ((In, Static) => OutB), (In, Static) => OutA) => (In, Static) => OutB,
  chain: ((In, Static) => OutA, OutA => ((In, Static) => OutB)) => (In, Static) => OutB,
  {chain-types-expand, 2}
  ,
  {chain-types-expandAll, 2}
  ,
  {chain-types-combine, 2}
};

The bind method is the most complicated method so far. It's first argument is a function that generates a consumer. The second argument is the consumer that will be run first. Both the generated consumer and the input consumer must take the same Input and Static values. The whole method eventually returns a consumer that outputs the result of the generated consumer.

There is a single law that the bind interface needs to follow, and that is the law of associativity. It should not matter in which order I group method calls to bind using parentheses.

{chain-laws-associativity 2}
bind(g, bind(f, u)) === bind(x => bind(g, f(x)), u)

An implementation that satisfies this law looks as follows. I also include the implementation of chain as it is simply a flipped version of bind.

{chain-bind-implementation 2}
bind: (r_, r) => (
  (state, action) => (
    r_(r(state, action))(state, action)
  )
),
chain: (r, r_) => ChainI.bind(r_, r)

There are in addition some convenience methods that I have added to the Chain interface. The first of this is named expand. The expand method takes two consumers that output objects, runs them, and merges their output into the same object. The output is biased towards the second argument in case of conflicting keys in the output.

{chain-types-expand 2}
  expand: ((In, Static) => {[string]: OutA}, (In, Static) => {[string]: OutB}) => (In, Static) => {[string]: OutA & OutB}

The implementation uses bind.

{chain-expand-implementation 2}
expand: (r, r_) => (
  ChainI.bind((s_) => (s, a) => ({ ...s_, ...r_(s, a) }), r)
)

The expand helper is useful when I have exactly two consumers to expand, but often in Javascript I have more and nesting calls to exapnd is syntactically ugly. The expandAll method is provided to address that. It is a variadic version of expand. It's signature and implementation follows.

{chain-types-expandAll 2}
expandAll: (...Array<(In, Static) => {[string]: *}>) => (In, Static) => {[string]: *}

The implementation is straightforward. It reduces over the arguments, starting with an identity consumer and calling expand on each one. In this manner the merge is biased towards later arguments in case of name conflicts.

{chain-expandAll-implementation 2}
expandAll: (...consumers) => (
  consumers.reduce(ChainI.expand, Applicative.constant({}))
)

The final convenience method is a reimplementation of the combineReducers method from the popular redux library. The method provided by the redux library is useful but has some runtime checks aimed towards helping beginners of the library and consequently does not fit well into pipelines constructed with this module. Semantically the combine method is the same but without the runtime checks.

{chain-types-combine 2}
combine: ({[string]: (In, Static) => * }) => (In, Static) => *

It is implemented by first objectifying the consumers according to the provided specification. Next it takes those objectified consumers and passes them to the expandAll method. This returns a consumer that will run all the consumers, appropriately pulling their inputs off the state and placing their results back in the state object at the same key.

{chain-combine-implementation 2}
combine: (spec) => {
  const objectified = mapObjIndexed((r, k) => Profunctor.objectify(k, r), spec);

  return ChainI.expandAll(...values(objectified))
}

I wrap up the implementations in a module. Some of the methods require some imports from the ramda library.

{Chain.js 2}
import R from './ramda';
{import-profunctor, 9:3}
{import-applicative, 5:3}
{import-types-chain, 3}
const { mapObjIndexed, values } = R;

const ChainI : Chain<*, *, *, *> = {
{chain-bind-implementation, 2}
,
{chain-expand-implementation, 2}
,
{chain-expandAll-implementation, 2}
,
{chain-combine-implementation, 2}
};

export default ChainI;

3. Imports

The type definition for Chain needs imported.

{import-types-chain 3}
import type { Chain } from './types/Chain';

Used in section 2

Other modules need to import Chain.

{import-chain 3}
import Chain from './Chain';

Used in section 12:1


Previous ChapterNext Chapter