Back

Why you should always use guard statements

23 Apr 2024 (procedural-programming, code-style)

The real world is messy, and real world software has to deal with that messiness. Handling possible erroneous states can result in complex and unwieldy conditional logic. This is especially true when using languages with nulls and untyped languages, like JavaScript. You find yourself frequently adding if-statements to introspect types and check for nulls. Often in order for a function to run successfully, its arguments need to meet some specific conditions, and you need to check that these are met otherwise you might cause an error at runtime.

Further, for each check that might fail, you also want to log or throw an error to provide information about what went wrong. For this reason, the conditions can’t be composed together as a boolean expression, they need to be performed separately so that the error that gets thrown is specific to the check that failed. Performing these checks one after another quickly leads to deeply nested if-statements that make code hard to read. However, guard statements are a solution that is easy to implement and makes for code that is far easier to read and maintain.

I’ll give an example of the situation I’ve described. Let’s say we’re building a piece of software in which we hold details about people, and we have a function called setJob which, as you might have guessed, takes person and job as arguments and sets the person’s job.

function setJob(person, job) {
    person.job = job;
}

We’re using JavaScript, so although person should be an object representing a person and job should be a string, our only way to check this is to do so manually in if-statements.

function setJob(person, job) {
    if (typeof person === 'object') {
        if (typeof job === 'string') {
            person.job = job;
        } else {
            throw new Error(`${job} is not a string`);
        }
    } else {
        throw new Error(`${person} is not a Person object`);
    }
}

There are also certain states the person object might be in which should prevent us from being able to set their job. For example, if the person is retired, or if they are not an adult.

function setJob(person, job) {
    if (typeof person === 'object') {
        if (typeof job === 'string') {
            if (!person.retired) {
                if (person.age >= 18) {
                    person.job = job;
                } else {
                    throw new Error('Cannot set the job of someone under 18.');
                }
            } else {
                throw new Error('Cannot set the job of a retired person.');
            }
        } else {
            throw new Error(`${job} is not a string`);
        }
    } else {
        throw new Error(`${person} is not a Person object`);
    }
}

This has become a very unwieldy piece of code. There are 3 major problems with this style, that I can see:

  1. The function’s happy path is the expression person.job = job, but because this is buried in deeply nested if-statements, it isn’t easy to identify it.
  2. Because of the nested structure, each if-statement’s else block is separated from its condition. The more nested ifs we add, the harder it is to match up a condition with its else block.
  3. Just like when reading a text with nested sections, there is mental overhead involved in holding the context of each layer of nesting in your head, which could be reduced by using a flatter structure.

Let’s refactor the code to use guard statements and compare. To switch to the guard statement style, we have to invert all of the conditions; we’ll now be performing the opposites of our previous checks. Then, because throw stops the execution of the function, none of the checks need an else block, as any code that comes after the throw will necessarily not run. This works when failed checks result in a return statement too, as returning early also halts the function’s execution.

function setJob(person, job) {
    if (typeof person !== 'object') {
        throw new Error(`${person} is not a Person object.`);
    }

    if (typeof job !== 'string') {
        throw new Error(`${job} is not a string.`);
    }

    if (person.retired) {
        throw new Error('Cannot set the job of a retired person.');
    }

    if (person.age < 18) {
        throw new Error('Cannot set the job of someone under 18.');
    }

    person.job = job;
}

As a result, we’ve removed the nested structure and switched to a flat list of conditions which is much easier to navigate. If we need to add more conditions, the function gets longer, not deeper—like adding more items to a list, not embedding more sections into an existing section. And unlike in the previous version, the result of a failed check (the throw) sits right next to the condition; we can more easily see what happens if a check fails. Furthermore, the happy path sits at the end of the function, which makes sense semantically as it sits after all of the checks it depends on. This will always be the case if guard statements are used consistently—the structure of a function is always “perform checks” THEN “execute happy path”.

This style of code is sometimes referred to as “never-nesting”, which is a slight misnomer, as we don’t remove all the nesting from our code when we follow this pattern. The point, I think, is to acknowledge that when you add a nested block, you’re creating a nested context for a block of code to exist in. That context, and all of the higher contexts the block is within, have to be kept in mind when navigating the code. Using a flat, non-nested structure allows the code to read like a simple checklist, rather than like a complex legal document with sections within sections. Your function lists some criteria that have to be met, then it does some work. For that reason, if we can reasonably remove a layer of nesting, it is good to do it.

I have seen certain cases where using a guard statement would lead to less expressive code than nesting if-statements, but this is not the norm. I think it is better to treat guard statements as the default and only introduce nesting where there is a clear reason to do so.