Skip to main content

Look mum, no point!

Reducing repetition using point-free functions.

Published
Updated

When working on Pebble, I noticed that two functions shared very similar signatures and wondered if it was possible to write in a more streamlined fashion.

store.set(state => produce(draft => {
  create(card)(draft);
  push(lane.id)(card)(draft);
})(state));

Both create and push take two parameters, card and draft, and state is passed to produce. It looks somewhat... uneasy? It's hard to tell exactly why it looks off but the duplication of arguments seems unneccesary.

Surely there's a way to pass these arguments around better?

Spicy curry

The first step I took in reducing the amount of arguments is currying state.

// Before
store.set(state => produce(draft => {
  create(card)(draft);
  push(lane.id)(card)(draft);
})(state))

// After
store.set(produce(draft => {
  create(card)(draft);
  push(lane.id)(card)(draft);
}));

It's kinda obvious when pointed out like this, but there was no need to write the argument state to a function that accepts state. It's the basis of currying.

Looking at create and push though, it's easy to spot that they both share the same arguments, card and draft. How can they be joined together?

const join = <T, K>(...fns: Array<(x: T) => K>) =>
  (x: T) =>
    fns.map(fn => fn(x));

store.set(produce(join(
  create,
  push(lane.id)
)(card)));

It's a good start, but it's not quite right. Perhaps not immediatly obvious, join returns an curried function that returns an array of curried functions.

const f = (a: number) => (b: number) => a + b;
const g = (a: number) => (b: number) => a + b;

const x = join(f, g); // (x: number) => Array<(b: number) => number>

const y = x(3); // Array<(b: number) => number>
const z = y(3); // Error

Unfortunately, when looking at the types in-depth, it doesn't quite work as intended. The function signatures of f and g do not match with x, which throws an error.

This is exactly what happened when I tried to join create and push

const x = join(
  create,
  push(lane.id)
); // (card: string) => Array<(draft: Draft) => void>

store.set(produce(draft => x(card)(draft))); // Error

join would error on the second argument draft. An array is not a function after all.

So the obvious thing to do would be to map (or forEach, as return type is void) over the array and call the curried functions.

const x = join(create, push(lane.id));

store.set(produce(draft => x(card).map(draft)));

Or better yet

const x = join(create, push(lane.id));

store.set(produce(x(card).map));

Point-free curry

If the curried map made you feel uneasy, good!

It doesn't actually work.

const f = (a: number) => (b: number) => a + b;
const g = (a: number) => (b: number) => a + b;

const x = join(f, g); // (x: number) => Array<(b: number) => number>

const y = x(3).map; // <T>(fn: (x: (b: number) => number, i: number, arr: ((b: number) => number)[]) => T, thisArg?: any) => T[]
const z = y(fn => fn(4));

You might've noticed that map takes two parameters, fn and thisArg. Unfortunately (though perhaps obvious), map references itself and therefore requires this. By currying map, the this reference gets lost.

this is a magical keyword that I'm quite convinced is the bane of every JavaScript developer's existence at some point in their career. It's possible to use bind, but requiring an intermediate function breaks currying and defeats the whole point of the exercise.

const t = x(3); // Intermediate function to allow referencing

const y = t.map.bind(t);
const z = y(fn => fn(4));

It works, but it's not desirable. If only there was a way of calling map without a point...

const join = <T, K>(...fns: Array<(x: T) => K>) =>
  (x: T) =>
    fns.map(fn => fn(x));

const map = <T, K>(arr: Array<(x: T) => K>) =>
  (x: T) =>
    arr.map(fn => fn(x));

const f = (a: number) => (b: number) => a + b;
const g = (a: number) => (b: number) => a + b;

const x = join(f, g); // (x: number) => Array<(b: number) => number>

const y = map(x(3)); // (x: number) => number[]
const z = y(4); // [7, 7]

// Or
const x = join(f, g);
const y = join(...x(3));
const z = y(4);

// Or
const x = map([f, g]);
const y = map(x(3));
const z = y(4);

// Or
const z = map(join(f, g))(4);

Bon Appétit!

// Before
store.set(state => produce(draft => {
  create(card)(draft);
  push(lane.id)(card)(draft);
})(state));

// After
const join = <T, K>(...fns: Array<(x: T) => K>) =>
  (x: T) =>
    fns.map(fn => fn(x));

const forEach = <T, K>(arr: Array<(x: T) => K>) =>
  (x: T) =>
    arr.forEach(fn => fn(x));

store.set(produce(forEach(join(
  create,
  push(lane.id)
)(card)));

In conclusion, with an understanding of currying and return types, it's possible to create point-free style code. It allows for the creation of new functions simply by composing smaller functions. Functional husbandry, if you will.

Is it appropriate to write this style of code? Maybe, maybe not. As arguments are hidden by design, it's harder to tell what's going on. This is mostly a curiousity piece, though splitting larger functions into composable blocks makes unit testing a lot easier.