The Array.reduce Method Part 6 - Transducers

Published: Thursday, June 2, 2022

Greetings, friends! This is Part 6 of my tutorial series on the Array.reduce method in JavaScript, and it's an interesting one! In this tutorial, I will discuss how to make transducers in JavaScript.

Reducer Review

First, let's refresh on what reducers are. In case you may have forgotten, a reducer is the name of the callback function we pass into the reduce or reduceRight method.

javascript
Copied! ⭐️
const sum = (accumulator, currentValue) => accumulator + currentValue;

const result = [1, 2, 3].reduce(sum);

console.log(result); // 6

In the example above, the function, sum, is the known as the reducer. We pass this function into the reduce method, and it is called multiple times.

What are Transducers and Why Use Them?

Transducers are simply reducers that return new reducers!

text
Copied! ⭐️
reducer = (accumulator, currentValue) => newAccumulatorValue

transducer = reducer => transformedReducer

That's it. Tutorial over. Just kidding 🙂

Transducers are composable transformations that we can apply to one or more values. We learned in Part 5 that we can apply function compositions easily using a compose function.

javascript
Copied! ⭐️
const compose = (...functions) => initialValue => functions.reduceRight(
  (acc, fn) => fn(acc),
  initialValue
)

const array = [-1, 1, -2, 2, -3, 3, -4, 4];

const mapDouble = array => array.map(x => x * 2);
const mapTriple = array => array.map(x => x * 3);
const filterEven = array => array.filter(x => x % 2 === 0);
const filterPositive = array => array.filter(x => x > 0);

// Similar to: mapTriple(filterEven(filterPositive(mapDouble(array))))
const compositeFunction = compose(
  mapTriple,
  filterEven,
  filterPositive,
  mapDouble
);

const result = compositeFunction(array);

console.log(result); // [6, 12, 18, 24]

There's a problem with this approach though. We are running a separate call to map and filter methods for each operation we supply to the compose function. That's a total of four iterations over the entire array: two map calls and two filter calls. That doesn't even include the iteration done by the reduceRight method inside the compose function. Therefore, we have a total of five iterations over array. Wow! What a waste of computational resources! Shame! Shame! 🔔

Transducers help overcome this dilemma. What if I told you that you only have to iterate through the array once? Exciting, right?! 😁

Transducers became really popular due to the Clojure programming language. As a result, very intelligent developers decided to build JavaScript libraries that utilized transducers such as Ramda and transducers-js.

In this tutorial, we won't be building transducers that are as sophisticated as Ramda. We will be building simple ones that hopefully prove how useful they are. Our goal is to perform as few iterations on an array as possible.

Let's look at an example of code that uses the native map and filter methods to transform an array, but causes the array to be iterated over multiple times.

javascript
Copied! ⭐️
const array = [1, 2, 3, 4, 5];

const isEven = x => x % 2 === 0;

const newArray = array
  .map((x) => x * 3)
  .map((x) => x + 1)
  .filter(isEven)
  .slice(0, 2); // grab the first two elements from the array

console.log(newArray); // [4, 10]

We learned in Part 2 that we can replace map and filter method chains with a reduce method. However, writing a reduce method to replace these method chains starts to become more complex as you apply more operations onto an array. Let's try replacing these method chains with an intelligent reducer that we can pass into the reduce method.

javascript
Copied! ⭐️
const array = [1, 2, 3, 4, 5];

const isEven = x => x % 2 === 0;

// acc = accumulator
// cur = currentValue
const newArray = array.reduce(
  (acc, cur) =>
    acc.length >= 2 ? acc : isEven(cur * 3 + 1) ? acc.concat(cur * 3 + 1) : acc,
  []
);

console.log(newArray); // [4, 10]

Now, the array is iterated over once. This isn't much code, but it doesn't look pretty. It would be nice if we could create a list of operations and apply them in a reducer automatically without having to worry about rewriting so much code.

How to Build Filter Transducers

We don't want to run operations that iterate the array multiple times like the native map and filter methods in JavaScript. Therefore, we'll have to create our own versions of them in the form of transducers. Remember, transducers are functions that returns a new reducer. Think of them as higher-order reducers, similar to higher-order functions. A higher-order function returns a new function. A higher-order reducer returns a new reducer.

Let's start with creating a "filter" transducer.

javascript
Copied! ⭐️
const filterTransducer = (predicate) => {
  return nextReducer => (acc, cur) => predicate(cur) ? nextReducer(acc, cur) : acc;
}

Let's analyze this code carefully. The filterTransducer function accepts a predicate, a test that is normally passed into the native filter method.

javascript
Copied! ⭐️
const isEven = x => x % 2 === 0;

const result = [1, 2, 3, 4, 5].filter(isEven);

console.log(result); // [2, 4]

In the code snippet above, isEven would be the predicate.

Next, the filterTransducer function will return a function that accepts a reducer. Then, it returns another reducer. It might look confusing with arrow functions, so let's rewrite the code using function statements.

javascript
Copied! ⭐️
function filterTransducer(predicate) {
  return function(nextReducer) {
    return function(acc, cur) {
      return predicate(cur) ? nextReducer(acc, cur) : acc;
    }
  }
}

Lots of nested functions, right? The filterTransducer is actually saying to run the next reducer only if the predicate is true. If the predicate is false, then we'll prevent the next reducer from ever seeing this value.

Let's see how we can use this filter transducer.

javascript
Copied! ⭐️
function filterTransducer(predicate) {
  return function(nextReducer) {
    return function(acc, cur) {
      return predicate(cur) ? nextReducer(acc, cur) : acc;
    }
  }
}

const isEven = x => x % 2 === 0;

// pass a predicate to the filter transducer
const transduce = filterTransducer(isEven);

// use our filter transducer to "transduce" or modify a reducer, so it becomes a new reducer
const filterReducer = transduce((acc, cur) => acc.concat(cur));

// run the reduce method with our new reducer and set the inital value to an empty array
const result = [1, 2, 3, 4, 5].reduce(filterReducer, []);

console.log(result); // [2, 4]

You have just created a transducer that behaves similar to the filter method! Awesome! 😎

I think the most important line in the code is the following:

javascript
Copied! ⭐️
const newReducer = transduce((acc, cur) => acc.concat(cur));

We have to pass in a "default" reducer, so that we have a starting point. Transducers must first accept a reducer, so that it can return a new, modified reducer. In our example, we create a very simple reducer that concatenates or adds new values to an array. Effectively, it just returns the same array we call the reduce method on.

javascript
Copied! ⭐️
const concatReducer = (acc, cur) => acc.concat(cur);

const initialValue = [];

const result = [1, 2, 3, 4, 5].reduce(concatReducer, initialValue);

console.log(result); // [1, 2, 3, 4, 5]

We then take this boring concatReducer and use a transducer to transform it into a reducer that can filter values.

javascript
Copied! ⭐️
const filterReducer = transduce((acc, cur) => acc.concat(cur));

This reducer will only filter out even numbers because we passed isEven as our predicate.

How to Build Map Transducers

We learned how to create our own filter transducer to replace the need for native filter methods. Now, we need to create our own map transducer to replace the need for map methods.

javascript
Copied! ⭐️
const mapTransducer = (fn) => {
  return nextReducer => (acc, cur) => nextReducer(acc, fn(cur));
}

If we rewrite this code using function statements instead of arrow functions, we obtain the following code:

javascript
Copied! ⭐️
function mapTransducer(fn) {
  return function(nextReducer) {
    return function(acc, cur) {
      return nextReducer(acc, fn(cur));
    }
  }
}

This transducer accepts a function, fn, as a parameter. When iterating over an array, the new reducer will wrap each value by this function.

Let's see how we can use this map transducer.

javascript
Copied! ⭐️
function mapTransducer(fn) {
  return function(nextReducer) {
    return function(acc, cur) {
      return nextReducer(acc, fn(cur));
    }
  }
}

const double = x => x * 2;

// pass a function to the map transducer
const transduce = mapTransducer(double);

// use our map transducer to "transduce" or modify a reducer, so it becomes a new reducer
const mapReducer = transduce((acc, cur) => acc.concat(cur));

// run the reduce method with our new reducer and set the inital value to an empty array
const result = [1, 2, 3, 4, 5].reduce(mapReducer, []);

console.log(result); // [2, 4, 6, 8, 10]

Again, we start with a boring concatReducer and use a transducer to transform it into a reducer that can map over values and perform an operation on them.

javascript
Copied! ⭐️
const mapReducer = transduce((acc, cur) => acc.concat(cur));

Composing Transducers

The main reason why we created filter and map transducers was to make sure that we only iterate an array once when performing multiple transformations. By creating our own filter and map transducers, we can compose them using the compose function I discussed in Part 5.

javascript
Copied! ⭐️
const compose = (...functions) => initialValue => functions.reduceRight(
  (acc, fn) => fn(acc),
  initialValue
);

const filterTransducer = (predicate) => {
  return nextReducer => (acc, cur) => predicate(cur) ? nextReducer(acc, cur) : acc;
}

const mapTransducer = (fn) => {
  return nextReducer => (acc, cur) => nextReducer(acc, fn(cur));
}

const isEven = x => x % 2 === 0;
const double = x => x * 2;

const evenFilterTransduce = filterTransducer(isEven);
const doubleMapTransduce = mapTransducer(double);

const concatReducer = (acc, cur) => acc.concat(cur);

const composedTransduce = compose(evenFilterTransduce, doubleMapTransduce);
const combinedReducer = composedTransduce(concatReducer);

const result = [1, 2, 3, 4, 5].reduce(combinedReducer, []);

console.log(result); // [4, 8]

We effectively ran a custom filter and map operation over an array, but only iterated through the array once!!! This can drastically speed up our code.

In the example above, we are using the compose function to create a composition out of the transducers. We then can create a new "combined" reducer that modifies the concatReducer to both filter and map over values in an array.

Pay attention to the order by which the operations are applied.

javascript
Copied! ⭐️
const composedTransduce = compose(evenFilterTransduce, doubleMapTransduce);

In our example, the "even filter" was applied before the "double map." We know this is true because our final result contains an array with just two values. If we had doubled the values in the array first, then they all would have been even values. The final result would have then contained five elements, not two.

What Libraries Implement Transducers?

Multiple libraries already implement transducers such as Ramda and transducers-js. Observable pipelines in RxJS are also very similar to transducers. If you'd like to use transducers in your code, I'd recommend using one of these libraries instead of writing transducers from scratch.

These libraries have utilities to make it easier to work with transducers and have very performant code. In the transducers we made in this tutorial, we iterated over an array just once, but it would be nice to terminate or "cancel" the reducer early. It would also be helpful if we could use transducers on other types of objects instead of only arrays. These libraries take care of these features for us.

Ramda and transducers-js have already optimized transducers and follow a common protocol. By following the same protocol, it allows interoperability between libraries. You'll be able to use transducers from other libraries or create your own and then use them in Ramda.

Conclusion

Transducers are perhaps the most complicated tools we can build out of reducers. However, they are extremely powerful and help us make highly performant code. Instead of chaining a bunch of filter, map, and/or reduce methods, we can iterate through an an array only once.

This concludes my tutorial series on the reduce method. I hope you learned a lot! The reduce method is indeed one of the most powerful methods in all of JavaScript. If you take time to understand how to wield it, you will surely become a mighty developer!!!

Resources