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
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
isOverLimit(9000, 9001) will be
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
In my original code, perhaps I'd have
const isOverGoldLimit = x => x > goldLimit, and I'd maintain
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)
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
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)
Pure functions can not cause anything to happen to any values outside of the function themselves. Consider the difference between
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. :)
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.