Of maps and pipes, chains and nests, and more
Can we combine functions by using mapping? What about this in relation to function composition and pipelining? And what does it have to do with chaining and nesting? This article will reply to all those questions, showing you how to join functions together for clearer, more expressive code.
Discover how at OpenReplay.com.
The array .map(...)
method is very powerful and lets us write more understandable code. Let’s ask a theoretical question and see where it takes us. Could we apply map()
to other types and not only to arrays? Why not?
In this article we’ll start by extending types to allow mapping, and then get into pipelines, chaining, nesting, and several other functional programming concepts; just wait!
Mapping for other types
First, we could add it to basic types, such as boolean
s, number
s, bigInt
s, and string
s. On the other hand, it seems that adding mapping to null
, undefined
, and symbol
wouldn’t be that great, so we’d skip that. And, obviously, adding a map()
method to objects (or to class definitions) is straightforward; no mystery there.
In all cases code would be something like this — we’ll only show two definitions to avoid repetition:
Boolean.prototype.map =
function(fn) {
return Boolean(fn(this));
};
Number.prototype.map =
function(fn) {
return Number(fn(this));
};
We can now write things like the following two examples, and let’s use pointfree style for a more “functional-programming-ish” style:
const not = (x) => -x;
let someFlag = true;
someFlag.map(not); // false
const negate = (z) => -z;
let aNumber = 22;
aNumber.map(negate); // -22
This isn’t a real win; we could have written not(someFlag)
and negate(aNumber)
equally well. There’s a slim advantage: we can now clearly chain sequences of operations. Let’s go with a simple example.
const add1 = z => z+1;
const times10 = z => 10 * z;
aNumber.map(add1).map(times10).map(negate); // -230
This starts to look more interesting; the alternative way, negate(times10(add1(aNumber)))
wouldn’t have been equally clear. We’ll look into this a bit more, but first, we must answer the question: what about using map()
with functions?
Mapping functions
Functions are (trivially!) essential, and we should investigate what mapping would mean for them. The logical definition would be as follows; if we have f1
and f2
functions and we write f1.map(f2)
, that should return a function that first runs f1
and then applies f2
to whatever f1
returned.
Function.prototype.map = function (fn) {
return (x) => fn(this(x));
};
add1.map(times10)(3); // 40
So, x => f1.map(f2)(x)
is equivalent to x => f2(f1(x))
, and in general x => f1.map(f2).map(f3)...map(fn)(x)
would be the same as x => fn(...f3(f2(f1(x))))
; this is a well known functional programming concept that we should look into!
Maps as composition?
The kind of expression we wrote in the previous paragraph, when you have a sequence of functions call in which the output of one function is the input of the next one, is called composition. Formally, in mathematical terms, instead of fn(...f3(f2(f1(x))))
you could also write (fn∘...∘f3∘f2∘f1)(x)
. If you wish, you can read ∘
as after, so f2∘f1
is f2
after f1
, etc.
If we wish, we can write a general compose(...)
function; it’s not hard - can you see why and how works the code below?
function compose(...fns) {
return fns.reduceRight(
(f, g) =>
(...args) =>
g(f(...args)),
);
}
So, instead of writing x => f1.map(f2).map(f3)(x)
you could go with x => compose(f3, f2, f1)(x)
- but that goes a bit against the grain; the order of functions is reversed! There’s another concept we should look into: pipelining!
Maps as pipes
In Unix and Linux, the idea of running a first command, passing its output as input to a second command, whose output will be input for yet a third command, etc., is called a pipeline. This is a basic philosophy of Unix, and Doug McIlroy, the creator of the pipelining concept, explained:
- Make each program do one thing well. To do a new job, build afresh rather than complicate old programs by adding new features.
- Expect the output of every program to become the input to another, so far unknown program.
To give an elementary example (from my Mastering JavaScript Functional Programming book) if you wanted to count how many LibreOffice text documents there are in a directory, the following pipeline would do:
ls -1 | grep "odt$" | wc -l
How does this work?
- The first command lists all the files in a single column, one filename per line.
- The second command gets the list of files and filters (let pass) only those that end with “odt”, the standard file extension for LibreOffice Writer documents.
- The third command gets the filtered list and counts how many lines there are in it.
It seems then that something like f1.map(f2).map(f3)
is like a Unix pipeline, sort of f1 | f2 | f3
thing. It so happens that there is a proposal to include such an operator in JavaScript, written |>
as in f1 |> f2 |> f3
, but it isn’t accepted yet.
Given that pipelining is just like composition but working “the other way”, we can quickly whip up the following:
function pipeline(...fns) {
return fns.reduce(
(result, f) =>
(...args) =>
f(result(...args)),
);
}
In that case, we could write any of the following versions — though the last two won’t work until the pipeline operator proposal is accepted:
aNumber.map(add1).map(times10).map(negate);
aNumber.map(add1.map(times10).map(negate));
pipeline(add1, times10, negate)(aNumber);
aNumber.map(pipeline(add1, times10, negate));
(add1 |> times10 |> negate)(aNumber);
aNumber.map(add1 |> times10 |> negate);
What do you think? Personally, I believe that this ”chaining style” for successive functions is clearer than the alternative ”nesting style” (based on composing) which adds the extra complexity of listing the functions in reverse order. The piping way, the fluent interface pattern, is more understandable, and adding our map/pipe
method allows us to easily use it.
Conclusion
We started this article by wondering about extending the usage of map()
to other data types, went from there to using it on functions, and ended up considering typical functional programming topics such as composition and pipelining. JavaScript can be considered a multi-paradigm language, and the examples we’ve seen here confirm that… and also allow you greater freedom to achieve the maximum clarity in your code!
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.