The part worth taking
Functional programming, in the form that is useful in JavaScript, is a small set of constraints:
- A pure function’s result depends only on its arguments. It does not read a clock, write to the DOM, or mutate its inputs.
- Data is treated as immutable. A transformation returns a new value.
- Functions are ordinary values, so they can be passed around and returned.
- A higher-order function is one that takes a function, returns a function, or
does both.
mapis the everyday example.
JavaScript already has the last two. Arrays give you map, filter, and
reduce. What the language does not give you is an argument order designed for
composition. array.filter(predicate) puts the data first, so the predicate has
to be written at the call site. Libraries such as Ramda, and the lodash/fp
build of lodash, flip that: the function you already know comes first, and the
data comes last. You can configure the transformation before the data exists,
then hand the data to the finished function.
That is the feature. The rest of this article is when it pays off, and the traps in each library’s version of it.
A pipeline either way
Take a list of orders and compute the revenue of the ones that have shipped. Amounts are in cents.
const orders = [
{id: 1, status: 'shipped', total: 1200},
{id: 2, status: 'pending', total: 800},
{id: 3, status: 'shipped', total: 450},
]
function shippedRevenue(list) {
return list
.filter((order) => order.status === 'shipped')
.map((order) => order.total)
.reduce((sum, total) => sum + total, 0)
}
shippedRevenue(orders) // 1650
This is already functional. Each step returns a new array or a number, and
orders is unchanged. The reason to reach for a library is reuse. The predicate
and the projection are stuck inside this function. Pull them out and the same
pieces can be assembled in more than one order.
const isShipped = (order) => order.status === 'shipped'
const toTotal = (order) => order.total
lodash/fp and Ramda both let those pieces become a pipeline that waits for the
list. The native version can do that too, by wrapping the chain in a function,
which is what shippedRevenue already is. The libraries start to matter when
many small functions are partially applied and passed around. They do not matter
for a chain you will write once.
What lodash/fp actually changes
lodash/fp is not a second library with new algorithms. It is lodash with its
methods wrapped. The FP guide
describes the wrapping as immutable, auto-curried, iteratee-first, data-last
methods. Five consequences follow, and they surprise people who know lodash.
The data moves to the end, and the iteratee moves to the front. Ordinary
lodash is _.map(collection, iteratee). The fp build is
fp.map(iteratee)(collection).
Methods are curried with a fixed arity. Optional arguments are removed,
because a curry cannot tell “you left this off on purpose” from “you will pass
it later”. padStart is the illustration in the guide:
import {padCharsStart, padStart} from 'lodash/fp'
// lodash: _.padStart('a', 3, '-') === '--a'
// The fp method no longer accepts the pad characters.
padStart(3)('a') // ' a'
padCharsStart('-')(3)('a') // '--a'
Iteratees are capped. Array.prototype.map calls the callback with
(value, index, array). That signature is a famous footgun with parseInt,
whose second parameter is the radix:
const parsed = ['6', '8', '10'].map(parseInt)
// parseInt('6', 0) -> 6
// parseInt('8', 1) -> NaN (1 is not a valid radix)
// parseInt('10', 2) -> 2
// parsed is [6, NaN, 2]
lodash/fp calls a map iteratee with the value only, so fp.map(parseInt)
parses all three strings in base 10 and returns [6, 8, 10]. reduce iteratees
are capped at two arguments, the accumulator and the value. If you need the key
as well, the guide’s escape hatch is convert, for example
fp.mapValues.convert({ cap: false }).
The operations return new values. fp.sortBy does not sort the array you
handed it.
There is no _.chain. Composition replaces chaining. flow runs left to
right, which reads like the pipeline. flowRight (aliased as compose) runs
right to left.
import {filter, flow, map, sum} from 'lodash/fp'
const shippedRevenue = flow(
filter((order) => order.status === 'shipped'),
map((order) => order.total),
sum,
)
shippedRevenue(orders) // 1650
filter and map are waiting for their collection. sum already has arity
one, so it receives the array of totals that flow passes along. The same
flow without sum is a reusable function that returns the list of shipped
totals, and other pipelines can start from it.
Currying is what makes add(1) a function:
import {add, flow, multiply} from 'lodash/fp'
const addOneThenDouble = flow(add(1), multiply(2))
addOneThenDouble(3) // 8
add and multiply keep their original argument order. They are in the guide’s
list of methods that are not rearranged, so add(1)(3) is 1 + 3.
Ramda starts from that design
Ramda was built around these constraints rather than wrapped into them. Every function is curried. The arguments least likely to change come first, so the data comes last. Optional parameters are avoided instead of being stripped later. The docs are at ramdajs.com/docs.
pipe is left to right, like flow. compose is right to left, mathematical
composition, where compose(f, g) means “do g, then f”. Reading a compose
pipeline top to bottom is the most common Ramda bug.
import {compose, filter, inc, map, multiply, pipe, sum} from 'ramda'
const shippedRevenue = pipe(
filter((order) => order.status === 'shipped'),
map((order) => order.total),
sum,
)
const addOneThenDouble = pipe(inc, multiply(2))
const sameThing = compose(multiply(2), inc)
addOneThenDouble(3) // 8
sameThing(3) // 8
Prefer pipe unless you specifically want composition written in mathematical
order. The two shipped-revenue pipelines, lodash and Ramda, are deliberately
almost the same. The difference shows up at the edges.
Ramda’s placeholder is R.__. It fills an earlier argument while you wait on a
later one:
import {__, gt} from 'ramda'
gt(2, 5) // false, because 2 > 5
gt(__, 2)(5) // true, because this is gt(5, 2)
lodash/fp has a placeholder too, also written __ in its docs, with the same
idea and a different catalogue of argument orders. Mixing the two libraries in
one pipeline means checking which argument moved. That is a good reason to pick
one.
A few Ramda functions have changed argument order across major versions.
propEq is one of them. An explicit predicate,
(order) => order.status === 'shipped', survives an upgrade. A point-free
propEq copied from an old post often does not.
Point-free has a ceiling
Point-free style means writing the pipeline without naming the value flowing
through it. pipe(filter(isShipped), map(toTotal), sum) is point-free. It is
pleasant when every step is a named function whose meaning is obvious, and it
turns opaque as soon as a step needs two inputs or a debugger.
When a pipeline misbehaves, the practical move is to break it open and name the intermediate:
const shipped = orders.filter(isShipped)
const totals = shipped.map(toTotal)
const revenue = totals.reduce((sum, total) => sum + total, 0)
You can put it back together once you can see which step was wrong. A style that forbids that is not buying clarity.
Purity has the same kind of limit. A function that formats an order total can be
pure. A function that submits the order cannot. Keep the pure core easy to call
in a test, and let the edge of the program do the fetch. Ramda and lodash/fp
do not help with the fetch. They help with the shaping of the data before and
after it.
Which one to use
Use neither when the transformation is a single chain local to one function.
Native map, filter, and reduce are obvious to every JavaScript reader, and
they allocate the same intermediate arrays these libraries do.
Use lodash/fp when the codebase already depends on lodash and you want to
compose those functions. Budget time for the five changes above. The main lodash
docs describe the unwrapped signatures, so the fp guide is the real reference:
data last, iteratee first, no optional arguments, capped iteratees.
Use Ramda when you are choosing a functional utility library on purpose. Its
argument order is consistent because it was never anything else, and pipe is
the default way to read a program top to bottom.
In both cases, name the small functions (isShipped, toTotal) and keep the
pipeline short enough to say out loud. That is the part of functional
programming that still matters after the import is gone.