How to Write Functions that Don't Lie

Dec 06, 2024

Here's a scenario:

You're working on a piece of code that checks if a user is eligible to vote in the United States. You have an array of users, and you want to filter them down to only those that are eligible to vote in the United States. So you write something along these lines:

const users = await getAllUsers();
const usersEligibleToVote = users.filter(user => isEligibleToVote(user));

function isEligibleToVote(user: User): boolean {
  return Date.now() - user.birthday >= EIGHTEEN_YEARS_IN_MS;
}

Looking at this code, there doesn't seem to be anything obviously wrong with it. But there is something interesting going on - isEligibleToVote doesn't actually answer the question "is this person eligible to vote?" It answers the question "is this person 18 or older?"

The reason we named it isEligibleToVote is because we knew that's how it was being used. Put another way, if someone asked you to write a function that tells you if a user is 18+, but didn't tell you the broader context of the problem, you wouldn't name that function isEligibleToVote, you would name it something like is18OrOlder.

This means that isEligibleToVote only makes sense within the context of how it's being used.

Why is this a problem?

On it's face, this doesn't really seem like that big of a problem. But here's the issue that could turn into a problem down the road - voter eligibility is based on more than just age.

isEligibleToVote hides the fact that it's only checking if a user if 18 or older. It does not contain any checks pertaining to citizenship. You may think I'm getting in the weeds of voter eligibility and this is a silly point, but this principal applies more generally. Often times when we write a function we assume the context in which it will be used, and those assumptions are then baked into the function. But we can't foresee all of the contexts in which the function will be used in the future.

In the above example, perhaps we know that our list of users are US citizens. Maybe we've already done that filtering elsewhere.

This becomes a problem when we want to reuse isEligibleToVote elsewhere. Perhaps in a different function we have a list of all users, and we have not first checked their citizenship. But we see a function called isEligibleToVote, so presumably we can pass in a user and that function will do the work for us, right?

If we're lucky we'll go look at the implementation of isEligibleToVote and very quickly realize it's not checking citizenship at all. But this is just a silly example to demonstrate a point - in the real world, many of our functions are much more complex. It may not be so obvious if isEligibleToVote is checking a user's citizenship.

How do we avoid this?

The best way to avoid this is to focus on the actual question that your function is answering and ignore the context in which it's being used. If I'm writing a function that returns true if a user is 18 or older, that's the question that is being answered. This function can now be used in multiple contexts (is this user old enough to vote? old enough to buy cigarettes? old enough to do [anything that requires someone to be 18 or older]).

Another example from a todo app:

function canSaveTodo(todo: Todo): boolean {
  return Boolean(todo.body?.trim());
}

What question is this function answering? Contrary to its name, it's not answering if the todo can be saved to the database. There may be multiple rules around if a todo can be saved. Or perhaps today there is only a single rule - that the todo must not be empty. But those requirements could change in the future. Maybe down the road we want to limit people to 5 todos at a time unless they upgrade to premium. If a free user already has 5 todos, can they save this 6th todo? canSaveTodo would tell you yes, and it would be wrong.

But what if we named this function doesTodoHaveBody? This completely alleviates any possibility of a misunderstanding in the future. And not only that, it makes this function more usable. Maybe we have a requirement to highlight the empty todo with a red border, to make it clear that the user needs to enter text. Now we can use doesTodoHaveBody in that context as well.

By thinking more deeply about what questions our functions are actually answering and naming them accurately, we can write functions that lead to fewer bugs and more re-use.