Pure functions, and why I like them.
Nimmo
Posted on September 17, 2017
Pure functions are not new. This isn't a new concept by any means at all, and this certainly isn't the first post anyone has written about them. But the benefits of pure functions are worth re-stating loud and often, because they make your life better. They're self-contained, they reduce cognitive load, increase testability, lead to fewer bugs, and are inherently reusable.
Before reading on, take a moment to consider what the following functions have in common.
const isOverLimit = x => x > limit
const multiply = x => x * config.requiredMultiple
const getItem = index => store[index]
const spaceAvailable = date => schedule[date].attendees < limitPerDay
Predictability
None of the example functions are complicated by any stretch, but one thing these examples have in common is that you can't look at them and know what their return value will be. You can see that isOverLimit
will return true or false, and you can infer that the point of that function is to find out if a supplied value is over a limit imposed by your system, but do you know whether it will return true
if you call it with isOverLimit(9000)
? You'd have to find out what limit
was pointing to for this, increasing your cognitive load unnecessarily, and making you look elsewhere in your codebase to understand the thing you were originally looking at; too much of that leads to distraction and frustration in equal measure, in my experience at least.
Consider this alternative:
const isOverLimit = (x, limit = 100) => x > limit
Now you can look at that function and see exactly what it'll return under any circumstance. You can see that isOverLimit(9000)
will be true
, and isOverLimit(9000, 9001)
will be false
.
Re-usability
Think again about my original isOverLimit
function. Imagine that my Product Owner comes to me one day and says that our company is adding a new "Gold" membership level to our product, with its own special limit of 1000
.
In my original code, perhaps I'd have const isOverGoldLimit = x => x > goldLimit
, and I'd maintain limit
and goldLimit
somewhere. I'd just keep writing this same function for every new membership level introduced, right?
But now that my isOverLimit
is pure, I can just re-use it:
const isOverGoldLimit = x => isOverLimit(x, 1000)
Testability
So the example multiply
function is working nicely in my imaginary system, which due to strict business requirements has to multiply things that we give it by a number that is set through a user's configuration, and can be updated at any time. Thanks to another business requirement, I'm not allowed to know what that number is. And thanks to a third business requirement, I must make sure I have an automated test that proves this function is working correctly. How do I do that? It doesn't take much to realise the answer is either "I can't", or if you're being generous, "with difficulty". But if I re-write it to be a pure function like I did with isOverLimit
, it would look like this:
const multiply = (x, y = config.requiredMultiple) => x * y
So, config.requiredMultiple
can still be whatever it was before, but crucially I can easily write a test that checks that my function is working: assert.equals(multiply(2, 4), 8)
No side effects
Pure functions can not cause anything to happen to any values outside of the function themselves. Consider the difference between array.push
and array.concat
in JS:
const updateItemsViewed = item => itemsViewed.push(item)
Great, this allows me to record what items have been viewed. But thanks to the side effect I've introduced here, this function doesn't give me the same output every time it's called with the same input. For example:
let itemsViewed = ['item1', 'item2', item3']
console.log(updateItemsViewed('item4')) // ['item1', 'item2', 'item3', 'item4']
console.log(updateItemsViewed('item4')) // ['item1', 'item2', 'item3', 'item4', 'item4']
Consider again the automated test for this function - the complication you should see immediately is that the test itself will alter my itemsViewed
, so when I run it a second time, it will add my test
item a second time. You've probably seen this before, where automated tests have a "setup" or "teardown" to deal with "resetting" any side effects the tests themselves have introduced. But if your function was pure in the first place, you wouldn't have this issue:
const itemsViewed = ['item1, 'item2', 'item3']
const updateItemsViewed = (item, itemsViewed = []) => itemsViewed.concat(item)
console.log(updateItemsViewed('item4', itemsViewed)) // ['item1', 'item2', 'item3', 'item4']
console.log(updateItemsViewed('item4', itemsViewed)) // ['item1', 'item2', 'item3', 'item4']
assert.deepEqual(updateItemsViewed('testItem'), ['testItem'])
Obviously the examples in this post are contrived to demonstrate the points I'm making, and of course you can't have a codebase entirely full of pure functions, unless the software you're writing is there to do nothing. But seriously, favour pure functions everywhere you can, and keep all your application's side effects to the "edges", and you'll thank yourself in the future. As will anyone else who has to look at your code. :)
TL;DR
Side effects are best avoided wherever they can be, and if you're strict about using pure functions you'll benefit from a codebase that is much easier to test, much easier to reason about, and much easier to extend and maintain. If your functions can be called without using their return value, then they're either not pure, or they're not doing anything. Either way, you can't re-use them or write tests for them (easily), and I'd strongly suggest you should consider changing them if they're anywhere but the very "edges" of your codebase.
Posted on September 17, 2017
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.