Demystifying chaining in Javascript
Lodash's chain function is no magic. Here's how to implement it
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 map
s, filter
s, 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.