Our code can get quite complicated. The events handlers are triggered, the HTTP calls are made. We make a lot of stuff happen that we don’t know the outcome of. The more dependencies, the more difficult it is to predict how the function might behave. In this article, we strive to make our code more testable and readable using the concept of pure functions.
The idea of pure functions comes from the functional programming paradigm. It gets more and more popular, and JavaScript is a fitting language for using the above style.
No side effects!
One of the main issues that we can experience with unpure functions are so-called side-effects. It is any influence on the outside from within a function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function fetchUsersWithPosts() { return Promise.all([ fetch('https://jsonplaceholder.typicode.com/users'), fetch('https://jsonplaceholder.typicode.com/posts') ]) .then(([usersRequest, postsRequest]) => Promise.all([ usersRequest.json(), postsRequest.json() ])) .then(([users, posts]) => { posts.forEach((post) => { const author = users.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } }) return posts; }) } |
The above function is far from pure. We can’t determine its outcome at all, because it relies on the outside world: the API. It is an example of a side effect. We can’t really write any unit tests for it in a straightforward way. Making real HTTP requests in our unit tests is not something we should do.
We can’t also give up on making an HTTP call. We can, on the other hand, delegate part of the logic to an additional function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function fetchUsersWithPosts() { return Promise.all([ fetch('https://jsonplaceholder.typicode.com/users'), fetch('https://jsonplaceholder.typicode.com/posts') ]) .then(([usersRequest, postsRequest]) => Promise.all([ usersRequest.json(), postsRequest.json() ])) .then(([users, posts]) => { allocatePostsInUsers(users, posts); return posts; }) } |
1 2 3 4 5 6 7 8 9 |
function allocatePostsInUsers(users, posts) { posts.forEach((post) => { const author = users.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } }) } |
There is still one wrong with our allocatePostsInUsers function. Can you spot it?
It mutates the arguments! We should never do that when aiming to write pure functions.
1 2 3 4 5 6 7 8 9 10 11 |
function getUsersWithPosts(users, posts) { const resultUsers = [...users]; posts.forEach((post) => { const author = resultUsers.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } }) return resultUsers; } |
Looks better, doesn’t it? We create a brand new array and return it. There is one issue though. With const resultUsers = [...users] we create a shallow clone of the users.
1 2 3 4 5 |
const author = resultUsers.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } |
With the code above, we modify objects that are both in the original and in the cloned array.
1 |
console.log(resultUsers[0] === users[0]) // true |
By writing code as above, we break the rule of not muting the arguments the function receives. To solve such problems, we can create a deep clone.
If you want to know more, check out Cloning objects in JavaScript. Looking under the hood of reference and primitive types
1 2 3 4 5 6 7 8 9 10 11 |
function getUsersWithPosts(users, posts) { const resultUsers = JSON.parse(JSON.stringify(users)); posts.forEach((post) => { const author = resultUsers.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } }) return resultUsers; } |
With the above approach, we don’t modify the users from the function arguments. We can also create a new instance of a user when adding the posts array.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function getUsersWithPosts(users, posts) { const resultUsers = [...users]; posts.forEach((post) => { const authorIndex = resultUsers.findIndex(user => user.id === post.userId); if (authorIndex > -1) { resultUsers[authorIndex] = { ...resultUsers[authorIndex], posts: [ ...(resultUsers[authorIndex].posts || []), post ] } } }) return resultUsers; } |
What approach do you think is better in this situation?
When looking for potential impure functions in your codebase, it might be a good idea to look at functions that don’t return anything. They might have some side effects!
No external dependencies!
When aiming to write pure functions, you need to keep an important rule in mind: output depends only on the input. Our pure functions shouldn’t access any variables outside, such as global variables.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import usersService from './usersService'; import postsService from './postsService'; function getUsersWithPosts() { const users = usersService.getUsers(); const posts = postsService.getPosts(); const resultUsers = JSON.parse(JSON.stringify(users)); posts.forEach((post) => { const author = resultUsers.find(user => user.id === post.userId); if (author) { author.posts = author.posts || []; author.posts.push(post); } }) return resultUsers; } |
The function above is not pure anymore because we depend on outside factors. We should provide all the dependencies in the arguments.
1 2 3 4 5 6 7 |
import usersService from './usersService'; import postsService from './postsService'; const users = usersService.getUsers(); const posts = postsService.getPosts(); function getUsersWithPosts(users, posts); |
When refactoring your code, a function that does not have any arguments might be a good candidate for an impure function.
No indeterministic output!
When a function is deterministic, it always returns the same output for a given input. An excellent example of an indeterministic function is the one that uses random numbers. While sometimes it is necessary, it would be tough to test such function.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function getRandomDayOfTheWeek() { const dayNumber= Math.floor((Math.random()*6)); const weekdays = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ] return weekdays[dayNumber]; } |
Let’s take a look at this very simple example above. Unfortunately, it might be quite difficult to write some tests for it, because it is not deterministic by design. What we could do is to break it down into two separate functions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function getRandomDayOfTheWeek() { const weekdayNumber = Math.floor((Math.random()*6)); return getDayOfTheWeek(weekdayNumber); } function getDayOfTheWeek(dayNumber) { const weekdays = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ] return weekdays[dayNumber]; } |
Now, one of the above functions is deterministic: the getDayOfTheWeek always returns the same output for a given input.
1 2 3 4 5 6 7 |
describe('The getDayOfTheWeek function', () => { describe('when provided with a number 3', () => { it('should return Thursday', () => { expect(getDayOfTheWeek(3)).toBe('Thursday'); }) }) }) |
Now we can test a part of our logic easily, even though it is indeterministic as a whole.
Summary
In this article, we’ve gone through the concept of pure functions. We’ve learned that such a function should not have any external dependencies. Also, it shouldn’t perform any side effects. On top of that, it should be deterministic. We’ve also acquited the knowledge on how to fix the above issues. Thanks to that, our functions are now more testable and we can depend on them more.