Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets and other freebies.
Often when we're developing a piece of code, we need to take one starting value and apply several functions to it before we return that value.
Something like:
const incompleteTasks = getIncomplete(tasks)
const withoutBlockedTasks = getNonBlocked(incompleteTasks)
const sortedByDueDate = sortByDueDate(withoutBlockedTasks)
const groupedByAssignee = groupByAssignee(sortedByDueDate)
// etc...
The problem with this is that it's difficult to read. Whenever you add intermediate variables (incompleteTasks
, withoutBlockedTasks
, etc.), you have to track which ones are passed as arguments to the next functions. So you do a lot of variable tracking when you're reading the code. And why create a bunch of intermediate variables if we don't end up using them anywhere else? It feels like a waste.
Sure, if it's only a couple of variables that shouldn't affect readability/understanding the code too much, but when you need to pass a starting value through lots of functions, it can get messy and painful quickly.
One way to get around using intermediate variables is to do something like:
groupByAssignee(sortByDueDate(getNonBlocked(getIncomplete(tasks))))
...but using nested functions like that makes it even more unreadable. And good luck adding debug breakpoints to that!
Functional programming to the rescue
Using a functional programming pattern called functional composition, we can get make something that is much more readable, without intermediate variables or nested functions.
Something that will make it much easier for those reading your code and reviewing your pull requests.
And everyone wants to be using functional programming these days - it's the cool thing to do now, and for good reason. I've found that just by using functional composition you can get pretty far and get many of the benefits of functional programming without having to learn the other more complex stuff, like what the hell a monad is.
So think of this as killing two birds with one stone! It'll make the code more readable and you'll get to use more functional programming.
Functional composition
Rather than first try to explain composition with a definition, let's look at it in code. Our original code, which gets the remaining open tasks per user for the iteration, would look like this:
const { pipe } = require('ramda')
// here are the individual functions, they haven't changed from the above,
// just including them so you can see their implementation
const getIncomplete = tasks => tasks.filter(({complete}) => !complete)
const getNonBlocked = tasks => tasks.filter(({blocked}) => !blocked)
const sortByDueDate = tasks => tasks.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
const groupBy = key => array => {
return array.reduce((objectsByKeyValue, obj) => {
const value = obj[key]
objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(obj)
return objectsByKeyValue
}, {})
}
const groupByAssignee = groupBy('assignedTo')
// this is the magic
const getIterationReport = pipe(
getIncomplete,
getNonBlocked,
sortByDueDate,
groupByAssignee
)
Pretty simple, right? We just put our functions into a pipe
function... and that's it! And to call the function, it's just:
const report = getIterationReport(tasks)
Wait, but I thought getIterationReport
was a variable, not a function?
Here we're using the pipe
function from the functional programming library Ramda. pipe
returns a function, so the value of getIterationReport
is actually a function. Which lets us then call it with whatever data we want, in this case tasks
.
Functional composition, thus, allows us to "chain" together functions to create another function. It's that simple! Instead of having to store the result of each step of transforming our original data like we did with the intermediate variable approach, we just define what those steps are.
This:
const getIterationReport = pipe(
getIncomplete,
getNonBlocked,
sortByDueDate,
groupByAssignee
)
is so much nicer than this:
const getIterationReport = tasks => {
const incompleteTasks = getIncomplete(tasks)
const withoutBlockedTasks = getNonBlocked(incompleteTasks)
const sortedByDueDate = sortByDueDate(withoutBlockedTasks)
return groupByAssignee(sortedByDueDate)
}
Kinds of composition
There are generally two kinds of composition - compose
and pipe
- compose being right to left, and pipe
being left to right.
I prefer using pipe
as it follows the Western standard of reading left to right (or top down, like we've formatted it here) and makes it easier to understand how your data will pass through each function sequentially.
On arguments
Most pipe
and compose
implementations will only operate on one argument - "unary" in FP terms. So composition is best fitted for functions that take one value (like our tasks
here) and operate on that value. Our getIterationReport
function would, as it stands now, not work if we had to pass in other arguments in addition to tasks
.
There are ways of transforming your functions to get around this, but that's outside the scope of this post.
Just know that if you're using Ramda's pipe, the first function may have any number of arguments, but the rest must be unary. So if you do have one function that requires multiple arguments, put it first in the pipe
.
The data and the result
Now to complete the rest of the picture, let's look at the data we'll call this function with:
const tasks = [
{
assignedTo: 'John Doe',
dueDate: '2019-08-31',
name: 'Add drag and drop component',
blocked: false,
complete: false
},
{
assignedTo: 'Bob Smith',
dueDate: '2019-08-29',
name: 'Fix build issues',
blocked: false,
complete: false
},
{
assignedTo: 'David Riley',
dueDate: '2019-09-03',
name: 'Upgrade webpack',
blocked: true,
complete: false
},
{
assignedTo: 'John Doe',
dueDate: '2019-08-31',
name: 'Create new product endpoint',
blocked: false,
complete: false
}
]
When we call the function, the result will look like:
{
'Bob Smith': [{
assignedTo: 'Bob Smith',
dueDate: '2019-08-29',
name: 'Fix build issues',
blocked: false,
complete: false
}],
'John Doe': [{
assignedTo: 'John Doe',
dueDate: '2019-08-31',
name: 'Add drag and drop component',
blocked: false,
complete: false
},
{
assignedTo: 'John Doe',
dueDate: '2019-08-31',
name: 'Create new product endpoint',
blocked: false,
complete: false
}
]
}
As you can see, we filtered out completed and blocked tasks, and grouped the tasks by the developer working on them.
While our task data structure is not super complex, hopefully this helps you see how easily and cleanly we can transform the data using composition and without having to resort to using intermediate variables to store each step of the sequence of transformations.
So the next time you find yourself writing code like:
const incompleteTasks = getIncomplete(tasks)
const withoutBlockedTasks = getNonBlocked(incompleteTasks)
const sortedByDueDate = sortByDueDate(withoutBlockedTasks)
const groupedByAssignee = groupByAssignee(sortedByDueDate)
// etc...
where you're storing each step result as a variable and just passing that result to the next function, use either compose
or pipe
from Ramda or whatever library you choose to make this much easier to read and reason about!
And if you found this post helpful, here's that link again to subscribe to my newsletter!