Demystifying chaining in Javascript

Lodash's chain function is no magic. Here's how to implement it

Author's image
Tamás Sallai
5 mins

Chaining

Chaining is when you wrap a collection, define the pipeline, then extract the result in the end. If you know Lodash's or Underscore.js's chain method, then you already know how to use it. In this post, we'll look into how it works, and implement this function in a somewhat simplified way.

In contrast to Array#Extras, chaining does not require any existing functions present in the Arrays themselves. In fact, it is a generic concept that can work on any data types and can add any processing functions.

Chaining in action

The structure is wrapping, processing, and extracting the result. The first part is done by the chain method, the second one is the collection pipeline itself (made of maps, filters, and the likes), and the final part is the value() call.

chain(collection)
	.map(iteratee)
	.filter(iteratee)
	.value();

An example processing (Try it):

chain([5, 10, 15])
	.map((i) => i + 3) // [8, 13, 18]
	.filter((i) => i % 2 === 0) // [8, 18]
	.value(); // [8, 18]

Demystifying

At first sight, it's like magic. How to convert the map and the filter functions we already have to chainable steps?

The crux of the implementation is, obviously, the chain method. This function gets a collection and returns an object that has all the processing functions and a fluent interface.

Let's build this function step by step! (Try it)

Keep in mind that this is a simplified version, and you can make it in different ways.

The processing steps

The 0th step is to have the map and filter functions ready! Let's just pull the ones we've already used before.

const map = (coll, iter) => {
	const result = [];
	for (let e of coll) {
		result.push(iter(e));
	}
	return result;
}
const filter = (coll, iter) => {
	const result = [];
	for (let e of coll) {
		if(iter(e)){
			result.push(e);
		}
	}
	return result;
}

The skeleton

The first step is to have a chain function that gets a collection and returns an object:

const chain = (arr) => {
	const wrapper = {};
	return wrapper;
}

Wrap the collection

The next step is to keep track of the current state of the collection, and provide a way to extract it with a value() call.

wrapper._currentArr = arr;
wrapper.value = () => wrapper._currentArr;

With only this in place, we've already made the wrapping and the extracting parts possible.

Attach the functions

The final step is to attach the processing functions in a chainable way. To do this, add a function with the same name, which gets some arguments and calls the processing functions (map or filter in our case) with the current collection (_currentArr) and the arguments.

To make it easier to digest, let's consider only one function, the map! To make it chainable, the wrapper needs a function called "map" that:

  • Calls the map implementation with the collection and other arguments
  • Updates the wrapped collection
  • And returns the wrapper for a fluent interface
wrapper["map"] = (...args) => {
	const newArr = map(wrapper._currentArr, ...args);
	wrapper._currentArr = newArr;
	return wrapper;
};

To add all the processing functions, use a loop:

[map, filter].forEach((func) => {
	wrapper[func.name] = (...args) => {
		const newArr = func(wrapper._currentArr, ...args);
		wrapper._currentArr = newArr;
		return wrapper;
	};
});

The final code

Now that we know all the parts, putting them together results in a fully functional chain implementation:

const chain = (arr) => {
	const wrapper = {};
	wrapper._currentArr = arr;
	[map, filter].forEach((func) => {
		wrapper[func.name] = (...args) => {
			const newArr = func(wrapper._currentArr, ...args);
			wrapper._currentArr = newArr;
			return wrapper;
		};
	});
	wrapper.value = () => wrapper._currentArr;
	return wrapper;
}

And its usage:

const result = chain(array)
	.map((i) => i + 3)
	.filter((i) => i % 2 === 0)
	.value();
console.log(result); // [8, 18]

Extending

In case you have a library of functions, just list them as processing functions and attach to the wrapper object. This way, the chain function can be customized with all the required processing steps.

But what if you use a third-party library, like Lodash or Underscore.js, and want to extend it?

Most libraries provide a way of adding functions. But they either modify the library itself, essentially polluting it, which we've already seen is not a good idea, or return a new chain method that makes the syntax awkward.

Minification

There is one other thing that might seem wrong with this solution.

It's on this line:

wrapper[func.name] = (...args) => { ...

func.name returns the name of the function. But what if you use an aggressive minifier that changes the names of the functions in order to save some bytes? In that case, func.name returns something other than "map" or "filter".

As a result, be extra cautious with variable renaming when you use chaining.

Conclusion

Chaining is a great solution if you have a library of functions and want to provide a fluent interface for them.

But keep in mind the tradeoffs. First, chaining is hard to extend if you use a third-party solution. And second, it might interfere with the minifier.

In the next episode, we'll look into a way of processing collections that is clear, easy to extend, and non-polluting.

December 12, 2017