A programming style I try to practice could be described as: “there should be only one way out of a function.” Early returns can often cause more confusion than they’re worth. When possible, I avoid them in favor of a single return.

Here’s a demonstration of this style.

Common Code: Early Returns

Imagine a function that takes as an argument an HTTP response object responding to .ok and returns a message string. Here’s an implementation, with an early return, when response.ok is truthy:

function flashMessage(response) {
  if (response.ok) {
    return 'It worked';
  } else {
    return 'It failed';
  }
}

Same behavior, different code: omitting the else and returning the second string if response.ok is falsy.

function flashMessage(response) {
  if (response.ok) {
    return 'It worked';
  }

  return 'It failed';
}

This is common code, and I think we can sometimes improve on it. Here’s the same behavior, but without the early returns.

function flashMessage(response) {
  let message;
  if (response.ok) {
    message = 'It worked';
  } else {
    message = 'It failed';
  }

  return message;
}

I chose a trivial example so I’d have to make a good argument1. I offer three:

Early returns…

  • Build dead code into the function
  • Are harder to read
  • Are harder to debug

Early Returns Can Build In Dead Code

Early returns build an opportunity for dead code into the function. Is response.ok ever falsy? It’s impossible to say with just this amount of context. Maybe response is an object like this:

const response = { ok: true };

Maybe our API is broken and it always returns ok. In either case, everything below that first early return would be dead code.

Early Returns Can Be Harder to Read

I find early returns, especially many of them, hard to read.

With a single return we can say: “we create a variable, adjust it based on some conditions, and then return it.”

With multiple returns, it’s more like “check something, then maybe return, or check something else, then maybe return, or…”, etc. If a measure of readability is how simply the code can be translated into words, one return is superior.

Early Returns Are Harder to Debug

My biggest issue with early returns is that they make debugging harder. If you want to know which conditional is evaluating with early returns, you have to stick a debugger or log statement into each one:

function flashMessage(response) {
  if (response.ok) {
    // Debugger or console.log here?
    return 'It worked';
  } else if(response.notFound) {
    // ...and here?
    return 'That record does not exist';
  } else {
    // ...and here? etc.
    return 'It failed';
  }
}

If the thing being returned is not a variable reference, such as normalize(prepare(items.map(toOption))) you have to rewrite the code in order to produce a useful log, while maintaining the return behavior.

With a single return, you can use one debugger or log statement and know it will always evaluate:

function flashMessage(response) {
  let message;
  if (response.ok) {
    message = 'It worked';
  } else if(response.notFound) {
    message = 'That record does not exist';
  } else {
    message = 'It failed';
  }

  // A debugger or console log here is always evaluated!
  debugger;
  return message;
}

And it rarely stays this simple. You end up adding more conditionals, and the returns grow. When there are five conditionals, those early returns become tough to reason about.

function flashMessage(response) {
  if (response.ok) {
    return 'It worked';
  } else if (response.unauthorized) {
    return "You can't do that";
  } else if (response.notFound) {
    return 'That record does not exist';
  } else if (response.unprocessable) {
    return 'Could not update with the data you provided';
  } else {
    return 'It failed';
  }
}

This is a gnarly function no matter how you write it. At least with a single return, we know the way “in” and “out” of the function.

This isn’t just an ergonomics argument. I can’t count the number of times I’ve been pairing on a bug, we aren’t using debuggers or log statements because of code like this, and we’ve been saying “it must be returning here” for an hour, only to find out that assumption is completely wrong. Code isn’t just for machines and humans, it’s for humans who are debugging, too.

Practical Application: Ambiguous Returns

Here’s a place where this technique shines: code that you aren’t sure returns.

Take navigate from the useNavigate hook of React Router. Here’s a function that you might see in the wild that handles redirection after successful login.

const handleRedirect = (booleanA, booleanB) => {
  if (booleanA) {
    navigate("/a-path");
  }

  if (booleanB) {
    navigate("/b-path");
  }

  navigate("/");
};

There’s a sneaky problem here: sometimes, even when booleanA or booleanB are true, the user ends up on the root path! How is that possible?

It’s possible because navigate doesn’t return! It does something return-like, which is that it changes the application’s route, which has a frequent side effect of unmounting the component. That feels like a return, but it isn’t. Sure, we could add a return after each if statement. But consider this alternative:

const handleRedirect = (booleanA, booleanB) => {
  let path = "/";

  if (booleanA) {
    path = "/a-path";
  } else if (booleanB) {
    path = "/b-path";
  }

  navigate(path);
};

By refactoring this code so that there’s only one way ‘out’ of the function, we’ve clarified how the story ends. If there’s a bug, it’s a bug in how path is assigned. It’s not a bug in how the function works.

Counterargument: Guard Clauses

Guard clauses are an exceptional case where I think early returns are nice. They say: “if this condition is true, exit!”

To revisit our example, perhaps we shouldn’t return a message if the response doesn’t respond to ok, because that indicates a bad response. Here’s a guard clause with optional chaining.

function flashMessage(response) {
  // If response doesn't respond to `ok`, return
  if (response?.ok === undefined) return;

  // ...rest of the function
}

I like guard clauses because they identify an exceptional state where the function should stop doing work.

Alternatives

My recent post Hash Fetch Instead of If/Else offers one example of an alternative to early returns. By using Ruby’s fetch, we let a language feature handle our conditional-style logic.


  1. And it mutates the message variable! We’re talking lesser of two evils. I think early returns are the greater evil. ↩︎