Lodash chaining alternative
Munawwar
Posted on July 15, 2020
Those of you dealing with data transforms/manipulations for charting/dashboards/whatever, need no introduction to Lodash library, and have been using it happily on the backend, frontend etc
The problem
However there is one method Lodash has that's so useful but has its performance implications on the frontend.. namely chain()
.
On the frontend if you naively import chain
the entire lodash library will end up in your bundle.. and the entire lodash library isn't small. Backend code doesn't care about some extra bloat.
That's sad. chain
is very useful and I would like to use chaining on the frontend without having to take a performance hit. So.. what is the solution?
What does Google say?
Googling around you would see many suggestions to use lodash/fp's flow()
method. You can see the code from this 2016 post
import map from "lodash/fp/map";
import flatten from "lodash/fp/flatten";
import sortBy from "lodash/fp/sortBy";
import flow from "lodash/fp/flow";
flow(
map(x => [x, x*2]),
flatten,
sortBy(x => x)
)([1,2,3]);
It works.. it keeps the bundle size small and gives you chaining capability.
But there is something bothersome about that code...
_.chain
begins with the data you need to manipulate and then you call the transforms.. whereas flow()
begins with the transforms and ends with the data you want to manipulate. This isn't natural to read. It needs to be flipped around.
[From flow()
's perspective it is built as intended. flow is potentially built for reuse. Fine. However we still miss a closer alternative to chain
.]
Alternative solution
My ideal syntax would be the following (for the same code sample from above):
chain([1,2,3])
(map, x => [x, x*2])
(flatten)
(sortBy, x => x)
();
However most linter configurations would complain about the indented parentheses. So we need a dummy function and a .value()
to break out of the chain (like lodash already does)
chain([1,2,3])
.fn(map, x => [x, x*2])
.fn(flatten)
.fn(sortBy, x => x)
.value();
Overall if you squint your eyes and ignore the .fn()
s, then it looks very similar to lodash's _.chain
syntax. And there is a way to implement this. I'll dive straight into the implementation which is small and probably don't need too much explanation:
function chain(value) {
return {
fn: (func, ...args) => chain(func(value, ...args)),
value: () => value,
};
}
This implementation brings some new opportunity considering how generic the approach is.
The function doesn't know anything about lodash. It takes in any function. So you can write custom functions or use Math.* or Object.* functions
chain({prop: 2, fallback: 1})
.fn((obj) => obj.prop || obj.fallback)
.fn(Math.pow, 2)
.value(); // result = 4
Improvement
With a slight modification, we can make it call any function on result objects.
Which mean for arrays, we can use native array map, filter etc, and we don't really need to use lodash's functions there. We should be able to do something like the following (using same example from before):
chain([1,2,3])
.fn('map', x => [x, x*2])
// ... blah
.value();
Instead of passing the function here we put a name of the method to be invoked from the intermediate result object/array. The implementation of fn
will change to the following:
fn(func, ...args) {
if (typeof func === 'function') {
return chain(func(value, ...args));
}
return chain(value[func](...args));
},
We can further improve this to handle promises / async functions:
fn(func, ...args) {
if (value instanceof Promise) {
return chain(value.then((result) => {
if (typeof func === 'string') {
return result[func](...args);
}
return func(result, ...args);
}));
}
if (typeof func === 'string') {
return chain(value[func](...args));
}
return chain(func(value, ...args));
},
And then it can be used as follows:
const dates = await chain(Promise.resolve([1, 2, 3]))
// with method name
.fn('map', x => x.toString())
// with functions
.fn((arr) => arr.map(x => new Date(x)))
.value();
I believe this is an improvement to the popular approaches suggested out there on the interwebz. Check it out, try it out.. criticism welcome.
That's all folks. Hope you liked my short, hastily written post.
Full code below:
/** @type {import('./chain').default} */
export default function chain(value) {
return {
fn(func, ...args) {
if (value instanceof Promise) {
return chain(value.then((result) => {
if (typeof func === 'string') {
return result[func](...args);
}
return func(result, ...args);
}));
}
if (typeof func === 'string') {
return chain(value[func](...args));
}
return chain(func(value, ...args));
},
value() {
return value;
},
};
}
chain.d.ts
declare type ParametersExceptFirst<F> = F extends (arg0: any, ...rest: infer P) => any ? P : never;
declare type MethodsOf<T> = {
[K in keyof T]: T[K] extends ((...arg: any[]) => any) ? T[K]: never;
};
export default function chain<V>(val: V): {
fn<Func extends (v: Awaited<V>, ...args: any[]) => any>(func: Func, ...args: ParametersExceptFirst<Func>): ReturnType<typeof chain<ReturnType<Func>>>;
fn<Name extends keyof MethodsOf<Awaited<V>>>(
func: Name,
...args: Awaited<V>[Name] extends ((...args: infer P) => any) ? P : never
): ReturnType<typeof chain<ReturnType<MethodsOf<Awaited<V>>[Name]>>>;
value(): V;
};
export {};
EDIT 2022: Added type definition and improved implementation
EDIT 2024: Added promise / async function handling
Posted on July 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.