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
Top comments (6)
I think the point of flow is that it defines a proper functional pipeline as a callable, that you will call later with the data. In functional programming, the data is normally last and the flow itself is also chainable and can be included inside a wider functional pipe. Chaining is the other way around. I'd argue that flow in the order it is presented by lodash is perfectly correct and makes total logical sense if you use it to define the function:
Got it. Yet it still leaves for a closer alternative for chain for the front end.
but the function still uses chain()
Where? That's my own chain implementation not using lodash chain.
I like this a lot. Very neat solution!
I've just been reading about the history of the various attempts at getting a pipe operator into Javascript, and its current (albeit stalled) direction, and imo this looks a lot cleaner and easier to understand than the Hack-style pipe operator that seems to have the most chance of actually getting adopted.
Some of the suggestions for what the placeholder character should be (here) would lead to some pretty nasty code imo. one of the suggestions for the placeholder character is
^^
, which would lead to :with your
chain
function, on the other hand, this becomes:a LOT cleaner and easier to understand and doesn't need special syntax to make it work.
i do think an F# style pipe operator would be the nicest but it doesn't sound like that'd ever get accepted into javascript:
Also - while I think what Mike Talbot says in another comment about
flow
defining a functional pipeline by composing functions is true, the chain-style approach is just as valid (and is the approach taken by most idiomatic Clojure code, for example, wherecomp
is much rarer than the threading macros).About flow, if you want to reuse, then flow makes sense. But again with chain you could do:
And then reuse: