Pure functions, immutability and other software superpowers
Pure functions are without a doubt the most important technique for writing software that I have learnt thus far in my career. This is because they help us simplify our code and make the effects of our code much more predictable.
What is so great about simple code?
A major part of writing good software is the art of keeping the complexity of our code to a minimum. Reducing complexity is useful when building applications, but damn near critical when working at scale.
So, why is it so important?
- Simple code is easier for people to read and understand. We don’t just write code for computers to process, we write it for humans to read as well.
- Simple code is easier to extend, change and delete. Well designed code is code that is easy to change.
- Simple code is easier to reason about. This makes it easier to debug and validate.
- Simple code is easier to write tests for.
- Simple code is predictable.
What is a pure function?
A pure function is a function that, given the same inputs, will always return the same outputs.
Let’s look at an example.
const add = (num1, num2) => {
return num1 + num2;
};const result = add(1, 5);
// result = 6
As we can see, the function add()
will always return the same output, given the same input. Regardless of how and when we call the add()
function it will always return 6, given the inputs 1 and 5.
This makes the output of this function incredibly predictable. Predictability is great because it makes it easy for anyone reading our code to understand the data flow of our application. Another huge benefit is that it makes testing our code easy.
const add = (num1, num2) => {
return num1 + num2;
};test('add() should correctly add numbers', () => {
expect(add(1, 5)).toBe(6);
expect(add(1, 0)).toBe(1);
expect(add(4, 100)).toBe(104);
});
The problem of time and shared state
Let’s look at a function that is not pure.
const timeSinceMoonLanding = () => {
const currentTime = Date.now();
const moonLandingTime = -14182980; return currentTime - moonLandingTime;
};
The above function is not pure for two main reasons:
- It relies on the
Date
object which exists outside of the scope of thetimeSinceMoonlanding()
function. - The output of
Date.now()
changes every millisecond. This meanstimeSinceMoonlanding()
will not return the same output given the same inputs.
Why should we avoid this?
- Testing the function becomes more difficult — we have to mock the
Date
object. - Our function is tightly coupled with the
Date
object. This means the function will break in any environment that doesn’t have access to theDate
object. - It much harder to extend the function — what if we want to calculate the time since moon landing for events other than the current time?
- To understand how the function works, we need to understand how the
Date
object works and how it may change over time. - To understand how the function works, we also need to understand how other parts of our program may operate and change the
Date
object. - If our function changes or modifies the
Date
object we need to understand how this may effect other parts of our program. - The time and order our functions are executed matters. This opens up a whole new group or bugs such as race conditions.
- Our function is harder to reason about and it can become hard to trace how it effects the state of our program.
- The behaviour of our function and the effect the function has on our program is unpredictable.
Let’s refactor our function to make it pure. We can do this easily by passing the current time in as a parameter.
const sinceMoonLanding = currentTime => {
const moonLandingTime = -14182980;
return currentTime - moonLandingTime;
};const moonlandingToNow = sinceMoonLanding(Date.now());
const moonlandingToYear2000 = sinceMoonLanding(946645200000);test('sinceMoonLanding() should return correct duration', () => {
const date1 = 1567563651281;
const date2 = 946645200000; expect(sinceMoonLanding(date1)).toBe(1567577834261);
expect(sinceMoonLanding(date2)).toBe(946659382980);
});
Notice how making our function pure has also made it easier to extend our function. We can now use it to calculate time gaps between the moon landing and other events like the year 2000.
The power of immutability
Immutable data structures are data structures that cannot be changed(mutated) after they are created. It is a powerful technique that allows us to make changes to our data structure without wiping state history. It also eliminates a whole class of nasty bugs(more on that later).
Let’s look at an example where we need to update and change the global state of our application. This example will show us the pitfalls of not using immutability. In this example we directly mutate our global state.
// our apps global state
const appState = {
items: [],
totalCost: 0,
};function addItem(item) {
appState.items.push(item);
appState.totalCost = appState.totalCost + item.cost;
};function removeLastItem() {
const lastItem = appState.items[appState.items.length - 1]; appState.totalCost = appState.totalCost - lastItem.cost;
appState.items.pop();
};addItem({
name: 'keyboard',
cost: 35,
});addItem({
name: 'laptop',
cost: 1250,
});addItem({
name: 'mouse',
cost: 15,
});removeLastItem();const result = appState;
Each function that modifies our state, addItem()
and removeItem()
, directly mutates our global state. This introduces a whole class of potential problems.
- The history of state updates is not preserved — making it hard to debug.
- To understand the effects of our function, we also need to understand how other parts of our program may operate, change or rely on the
appState
object. - The time and order our functions are executed matters. This opens up a whole new class or bugs such as race conditions. What happens if multiple parts of our program start operating on
appState
at the same time? - The behaviour of our function and the effect the functions have on our program is unpredictable.
Instead of mutating state, we can write functions that take the current state as an input, and return a new modified state as output. Pure functions are perfect for this.
// our apps global state
const appState = {
items: [],
totalCost: 0,
};const addItem = (state, item) => {
return {
items: [...state.items, item],
totalCost: state.totalCost + item.cost,
};
};const removeLastItem = state => {
const lastItem = state.items[state.items.length - 1] return {
items: [...state.items].slice(0, -1),
totalCost: state.totalCost - lastItem.cost,
};
};const stateUpdate1 = addItem(appState, {
name: 'keyboard',
cost: 35,
});const stateUpdate2 = addItem(stateUpdate1, {
name: 'laptop',
cost: 1250,
});const stateUpdate3 = addItem(stateUpdate2, {
name: 'mouse',
cost: 15,
});const result = removeLastItem(stateUpdate3);
This also gives us opportunities for better composition techniques. Let’s look at an example using a pipe to sequence our pure functions together.
import { pipe } from 'rambda';// our apps global state
const appState = {
items: [],
totalCost: 0,
};const addItem = item => state => {
return {
items: [...state.items, item],
totalCost: state.totalCost + item.cost,
};
};const removeLastItem = state => {
const lastItem = state.items[state.items.length - 1] return {
items: [...state.items].slice(0, -1),
totalCost: state.totalCost - lastItem.cost,
};
};const result = pipe(
addItem({ name: 'keyboard', cost: 35 }),
addItem({ name: 'laptop', cost: 1250 }),
addItem({ name: 'mouse', cost: 15 }),
removeLastItem
)(appState);
In summary
Let’s recap the benefits of pure functions and immutability.
- They reduce nasty bugs such as race conditions.
- Make it easier to debug and reason about our software.
- Becomes much simpler to write tests for our functions.
- Our software becomes more predictable.
- Our software becomes more extendable and composable.
Thanks for reading!