Functional JavaScript: transformers

In our previous article we covered some of the basics of functional programming. We discussed concepts such as immutability, purity, and arity, as well as operations like partial application, currying, and composition, all of them fundamental components of this paradigm.

In this article we will build on these basic operations to create some functions that will help us to process large datasets. But first, let’s briefly review composition.

Function composition

When we described function composition, we started with a common definition provided in algebra:

... process of transforming a sequential set of unary functions into nested ones, so that the result of one function is passed as a parameter to the next, wrapping it.

We achieved this by using a function called compose, which executes right-to-left:

language-javascript

⁠⁠const f = (x) => x * 2;
⁠const g = (x) => x * x;
⁠const h = (x) => x + 2;

const compose = (...fns) => (x) =>
⁠ fns.reduceRight((acc, curr) => curr(acc), x);

⁠const composed = compose(f, g, h);
⁠const result = composed(3);
⁠console.log(result); // 50
Compose, run me in Playground.

Or a pipe, executed left-to-right:

language-javascript

⁠⁠const f = (x) => x * 2;
⁠const g = (x) => x * x;
⁠const h = (x) => x + 2;

const pipe = (...fns) => (x) =>
⁠ fns.reduce((acc, curr) => curr(acc), x);

⁠const piped = pipe(f, g, h);
⁠const result = piped(3);
⁠console.log(result); // 38
Pipe, run me in Playground.

We also mentioned that composition is not commutative, f \circ g \neq g \circ f:

⁠language-javascript

⁠const composedA = compose(f, g);
⁠const composedB = compose(g, f);

⁠const isCommutative = composedA(3) === composedB(3);

⁠console.log(isCommutative); // false
Run me in Playground.

Although it is associative, f \circ (g \circ h) = (f \circ g) \circ h:

language-javascript

⁠const composedA = compose(f, compose(g, h));
⁠const composedB = compose(compose(f, g), h);

⁠const isAssociative = composedA(3) === composedB(3);

⁠console.log(isAssociative); // true
Run me in Playground.

Nothing new, this was all covered in our previous article. However it’s worth reviewing, as it is essential for what we are about to discuss next.

Working with iterables

So far we've seen how we can compose functions to work with primitives or objects; but we have skipped the case of iterables, such as arrays.

To handle them effectively we'll need to rely on some utilities, which we will implement next.

Reducers

The first function that will help us work with iterables is known as a "reducer": binary functions — with an arity of two — that combine two values into a single result.

A simple reducer has the following form:

⁠language-javascript

⁠const accumulate = (a, b) => a + b;
⁠console.log(accumulate(1, 2)); // 3

⁠const concat = (array, val) => [...array, val];
⁠console.log(concat([1], 2)); // [1, 2]

⁠const power = (base, exponent) => base ** exponent;
⁠console.log(power(2, 4)); // 16
Run me in Playground.

Although it may also merge two objects:

language-javascript

⁠const merge = (a, b) => ({ ...a, ...b });
⁠console.log(merge({ a: 1 }, { b: 2 })); // { a: 1, b: 2 }
Run me in Playground.

Or it may contain slightly more elaborate logic: for example, a reducer that takes a list and a value, which is added to each element of that list:

language-javascript

⁠const sumEach = (iterable, val) => {
⁠ const result = [];

⁠ for (const item of iterable) {
⁠ result.push(item + val);
⁠ }

⁠ return result;
⁠};

⁠const result = sumEach([1, 2], 3);
⁠console.log(result); // [4, 5]
Run me in Playground.

This last function is interesting, but it has a problem: it does two intertwined things. On one hand, it "reduces" two values into one — two integers into a single value, an array and an integer into another array, two objects into one, etc—. On the other hand, it contains some logic that is executed on the values to be reduced —adding a value to each item in the array—.

It would be perfect if we could decouple both tasks.

We can start by extracting the summing part, which we would pass as a callback, and that in turn would be a reducer. This way we can execute it with the iterable to be transformed, initialized with an initial value initialValue. And since our sumEach function will no longer be responsible for the summing, we can give it a name that aligns with what it does: reduce.

language-javascript

⁠const reduce = (reducer, iterable, initialValue) => {
⁠ let accumulation = initialValue;

⁠ for (const item of iterable) {
⁠ accumulation = reducer(accumulation, item);
⁠ }

⁠ return accumulation;
⁠};

⁠const data = [1, 2, 3];
⁠const increaseOne = (acc, val) => acc.concat([val + 1]);
⁠const result = reduce(increaseOne, data, []);
⁠console.log(result); // [2, 3, 4]
Run me in Playground.

Here we have already taken a step forward: we have managed to separate the summing operation from the reduction operation, and store it in increaseOne. And if this reduce function sounds familiar, it's because it is practically identical to JavaScript's Array.reduce method.

language-javascript

⁠const data = [1, 2, 3];
⁠const increaseOne = (acc, val) => acc.concat([val + 1]);
⁠const result = data.reduce(increaseOne, []);
⁠console.log(result); // [2, 3, 4]
Run me in Playground.

If we now recall our compose function, we’ll see that it is using Array.reduceRight().

language-javascript

⁠const compose = (...fns) => (initialData) =>
⁠ fns.reduceRight((acc, curr) => curr(acc), initialData);

Lets see what happens by replacing Array.reduceRight() with our reduce:

language-javascript

⁠const compose = (...fns) => (initialData) =>
⁠ reduce((acc, curr) => curr(acc), fns.reverse(), initialData);

⁠const functionA = (value: number) => value + 1;
⁠const functionB = (value: number) => value * 2;
⁠const functionC = (value: number) => value + 3;

⁠const composed = compose(functionA, functionB, functionC);
⁠const result = composed(1);

⁠console.log(result); // 9
Run me in Playground.

It works perfectly. This makes sense, as the input and output types are almost the same. It’s worth noting that we’ve reversed the array holding the functions to be composed with Array.reverse(), since compose must execute them right-to-left.

To implement pipe, we would proceed in the same way, but without reversing the array, as it should execute the functions left-to-right.

language-javascript

⁠const pipe = (...fns) => (initialData) =>
⁠ reduce((acc, curr) => curr(acc), fns, initialData);

⁠const functionA = (value: number) => value + 1;
⁠const functionB = (value: number) => value * 2;
⁠const functionC = (value: number) => value + 3;

⁠const piped = pipe(functionA, functionB, functionC);
⁠const result = piped(data);

⁠console.log(result); // 7
Run me in Playground.

Fantastic, we have now implemented our own Array.reduce(), which is agnostic to the reducer passed to it, and we have used it in our own compose and pipe functions.

Transformers

Now that we have our reduce function, we can take another step forward and explore what "transformers" are and what they are for.

One of the most common operations that may be performed on iterables is converting each of its elements from one type A to another type B.

To achieve this we can create a map function that will receive a transformation function, which we will call mapper, and whici will be responsible for performing the operation to transform each element of the iterable. This map will create another function that will accept a reducer, which in turn will handle the operation of accumulating the elements of the iterable into a given data structure—another array, an object, an integer, etc.—independently of the transformation performed on each element of the iterable. This second function will return another reducer, which will apply both operations —the transformation (the mapper) and the accumulation (the reducer)— to the iterable passed to it:

language-javascript

const map = (mapper) => (reducer) => (acc, value) =>
⁠ reducer(acc, mapper(value));

It sounds complicated, but let’s stick with the idea that our map, when called with a mapper, creates a function with a particular trait: it receives a reducer and returns another reducer.

Let’s look at the following example: given an array of integers, we can use the map function by passing it a function that will convert the integer it receives into a string. Then we call this function again, passing the accumulator to be used —returning a new array with concat— and finally pass this last function to reduce for it to be utilized.

language-javascript

⁠const map = (mapper) => (reducer) => (acc, value) =>
⁠ reducer(acc, mapper(value));

⁠const data = [1, 2, 3];
const integerToString = map((x) => x.toString());
⁠const integerToStringWithReducer = integerToString(concat);
⁠const result = reduce(integerToStringWithReducer, data, []);

⁠console.log(result); // ["1", "2", "3"]
Run me in Playground.

And in the same way we created a map function that transforms values, we could create a similar function to select elements from an array.

For example, instead of our map, we can create a filter function that takes another function as an argument, which we’ll call predicate, and evaluates whether a given element of the array should remain in the array or not. And just like in map, this second function will receive an accumulator responsible for the reduce operation to be performed and return another reducer. The difference is that in the case of filter, this last reducer will apply the predicate to the value in question, and if the result is true, it will add it to the array; otherwise it will discard it.

language-javascript

const filter = (predicate) => (reducer) => (acc, val) =>
⁠ predicate(val) ? reducer(acc, val) : acc;

We could use this filter function in a very similar way to how we used map.

language-javascript

const filter = (predicate) => (reducer) => (acc, val) =>
⁠ predicate(val) ? reducer(acc, val) : acc;

⁠const data = [1, 2, 3];
⁠const isOdd = filter((x) => x % 2 !== 0);
⁠const isOddWithReducer = isOdd(concat);
⁠const result = reduce(isOddWithReducer, data, []);

⁠console.log(result); // [1, 3]
Run me in Playground.

Just like with reduce, we can see that filter and map have their native counterparts in Array.map and Array.filter, producing similar results. However, in this case, our functions have a special trait: they can be composed.

For example, we may create two functions that first select the odd values and then add one to each of the resulting ones using pipe. ⁠

⁠language-javascript

const data = [1, 2, 3, 4, 5];

⁠const isOdd = filter((x: number) => x % 2 !== 0);
⁠const incrementValue = map((x: number) => x + 1);

const incrementOddValues = pipe(incrementValue, isOdd);
⁠const incrementValueWithReducer = incrementOddValues(concat);
⁠const result = reduce(incrementValueWithReducer, data, []);

⁠console.log(result); // [2, 4, 6]
Run me in Playground.

And just like before, composition is not commutative, so the order of the functions matters: if instead of using pipe — left-to-right — we use compose — right-to-left — we will see a different result.

language-javascript

const data = [1, 2, 3, 4, 5];

⁠const isOdd = filter((x: number) => x % 2 !== 0);
⁠const incrementValue = map((x: number) => x + 1);

const incrementOddValues = compose(incrementValue, isOdd);
⁠const incrementValueWithReducer = incrementOddValues(concat);
⁠const result = reduce(incrementValueWithReducer, data, []);

⁠console.log(result); // [3, 5]
Run me in Playground.

Conclusion

Let’s recap: we have seen what reducers are, what transformers are, and two functions related to them: mappers and filters.

  • Reducer: a pure function that accepts two values and reduces them into a single value. They cannot be composed, as they return a single value while accepting two.
  • Transformer: function responsible for transforming data in iterables. We use them to create other types of functions that can be easily composed.A function responsible for transforming data in iterables. We use them to create other types of functions that can be easily composed.
  • Mapper: function that accepts another function as a parameter, which converts data from type A to type B using the provided function.
  • Filter: function that accepts a predicate function —which returns a boolean —and creates another function that will use that predicate to determine if an element from the array should be kept in the returned value or not.

It’s important to have a clear understanding of these concepts, as they form the foundation for what we will cover next: transducers.

This is all for today. As always, the code can be run in the TypeScript Playground, and it’s also available for download at git.antoniodiaz.me.

If you have any questions or have found an error, feel free to reach out to me at the address shown in the footer.