Make your returns f**king explicit 🔥

How can the way we write our code set up future developers for success?

There are two audiences for code: The computer, which executes it, and a developer, who reads and writes it. Typically speaking, the computer executing the code is ambivalent to the contents. It cares not if your code is riddled with logic errors, spelling mistakes, logical fallacies, or hilarious puns.

The next developer who reads it, however, does care. They're the ones making decisions based on the code presented to them. They're the ones who will be attempting to understand the code, whether that's to confirm expected behaviour, to refactor code, to optimize it, or more. This person might be us, it might be a colleague, it might be someone who we have never, and will never meet. And every time we write code that is not clear and intentful, we do them a disservice.

Let's look at one very simple example of that, and what we can learn from it more broadly. While I will be looking at one particular example, this article is not actually about implicit returns; it's about how we can set up future developers for success with the code we write.

A close-up photo of a gorgeous fluffy fox, set against a snowy background.
This fox is considering how to make the intent of its code come across clearly. @howling red on Unsplash.

Fat arrow syntax in JavaScript 🏹

Here we'll be using Javascript and React, however the general principles apply far beyond there. In order to follow this article, there is one piece of Javascript specific syntax you need to understand, and that is the fat arrow function. First, we'll look at it with an explicit return:

const myFunction = (name) => {
  return "Hello " + name;
};

This is a very simple function which, given a name, prepends the word "Hello". The arrow syntax is syntactic sugar which expands to the following function:

const myFunction = function (name) {
  return "Hello " + name;
};

There is a shorter way to write this. By omitting the curly braces and return keyword, we can shorten the fat arrow quite a bit in this case.

const myFunction = (name) => "Hello " + name;

Now, before we go further, you might believe (based on the headline and the fact this is known as an implicit return) that I'm going to recommend you don't use this style. This is not the case - in fact, in short, simple situations, implicitly returned fat arrow functions are quite elegant. The issue stems from using an implicit return, when there was no need to return at all in the first place.

A common pattern

Let's take a look at a common pattern in React. Here we'll use an imaginary NumberInput component, which updates some state when it is updated.

function NumberCounter() {
  const [count, setCount] = useState(0);
  return <NumberInput onChange={() => setCount(count + 1)} />;
}

Passing a fat arrow function into the onChange prop for NumberInput represents a very common way of passing logic around in React. It's short, concise, and lives close to the place in code it is actually used. Some developers would prefer to extract the onChange function into a separate variable, but that's not what we'll focus on today.

Now, as we saw above, using an implicitly returned fat arrow function is the same as wrapping in curly braces and adding an explicit return.

<NumberInput
  onChange={() => {
    return setCount(count + 1);
  }}
/>

Now here comes a question: Say 6 months down the line, we want to refactor this code to log out the current time before and after it updates. How do we deal with the return value? What is the correct way to update the existing logic without breaking anything?

Without inspecting how NumberInput works, there is only one "correct" solution that we can be sure doesn't break:

<NumberInput
  onChange={() => {
    console.log("Before", new Date());
    let resultOfSetCount = setCount(count + 1);
    console.log("After", new Date());
    return resultOfSetCount;
  }}
/>

By storing the current count, we can then return it after the second log. But something feels wrong about this code. It's not a common pattern for an onChange prop in a React component to actually need a return value.

So why are we going to the effort of storing so we can return? The answer is simply because we don't know what issues not returning might cause.

In my opinion, this is a great reason at the implementation level, and a terrible reason at the architectural level. Fear of breaking things through changes is a big reason older code-bases can stagnate and be difficult to maintain.

Fearless refactoring

Breaking changes aren't necessarily a bad thing. The issue comes from when they're unexpected.

There are multiple ways to deal with this situation, and I'll suggest three, in order of weakest to strongest solution. All of these can be implemented at the same time, each with various advantages.

  1. Be explicit about your lack of return

Assuming you're writing plain JSX, without type safety, and no tests, here is one possible improvement.

<NumberInput
  onChange={() => {
    setCount(count + 1);
  }}
/>

By wrapping the fat arrow with curly braces, we've now made it explicit that this function does not return a value. It's now clearer to the next developer that it's okay to refactor this into a form that continues to not return.

But this is an isolated change; this doesn't solve this problem more widely, or for all cases of using NumberInput.

  1. Assertions and unit tests

Here we can automate our assumptions, but the way we might want to do that is quite awkward. It's not often we write tests to check that a function doesn't return something.

First we'll look at a basic implementation of NumberInput.

function NumberInput({ onChange }) {
  return (
    <input
      type="number"
      onChange={(e) => {
        try {
          let newNumber = Number.parseInt(e.target.value);
          onChange(newNumber);
        } catch {
          // It's not an error for someone to type a non-valid number into an <input> tag
        }
      }}
    />
  );
}

And now let's change it to a form that ensures the onChange prop doesn't actually return anything.

function NumberInput({ onChange }) {
  return (
    <input
      type="number"
      onChange={(e) => {
        let newNumber = null;
        try {
          newNumber = Number.parseInt(e.target.value);
        } catch {
          // It's not an error for someone to type a non-valid number into an <input> tag
        }

        if (newNumber !== null) {
          let onChangeResponse = onChange(newNumber);
          if (onChangeReponse !== undefined) {
            throw new Error("NumberInput onChange prop returned a value.");
          }
        }
      }}
    />
  );
}

Let's be clear here: this is a lot of extra code. I'm not suggesting you should always do this. And it's for a somewhat spurious reason - what's actually the issue if this onChange function returns a value?

The real point here is we've turned the code from being implicit about what its behaviour is, to being much more explicit. That is the goal of this article.

  1. Move it to compile time with type-safety

There's a far simpler way to get the intent of example 2, along with the specific code written in example one, that we can enforce automatically. This way is to use a type-checker, such as TypeScript or Flow.

We can do this trivially, if we write our NumberInput definition as such:

function NumberInput({
  onChange,
}: {
  onChange: (newNumber: number) => undefined,
}) {
  return (
    <input
      type="number"
      onChange={(e) => {
        try {
          let newNumber = Number.parseInt(e.target.value);
          onChange(newNumber);
        } catch {
          // It's not an error for someone to type a non-valid number into an <input> tag
        }
      }}
    />
  );
}

Pay attention to the parameters for NumberInput. All we've added is the following bit: {onChange}: {onChange: (newNumber: number) => undefined}. We've effectively taken all of the code we wrote in example #2, and moved it out of the runtime, and into the compile step. This way any incorrect code is caught well before we've spent the time running unit tests, or before we've manually clicked buttons on a webpage as part of Quality Assurance.

The original `NumberCounter` component, with a red squiggle under the `onChange` prop, denoting a TypeScript error.
Now we've got a TypeScript error, forcing us to handle the implicit return.

Conclusion

This is somewhat a sneaky article. The key idea I'm trying to share here actually has very little to do with return types and values (as you'll see if you read the bonus section.) I'm trying to share the idea that we should be intentful and explicit with our code. The next person who reads it might be you tomorrow, a colleague, someone you've never met, or worst of all: yourself in 6 month's time, late at night, time pulling you closer to a release deadline.

The best thing you can do for this next developer is leave the code in as clear a state as possible, in many ways. Obvious examples include things such as better variable names (temperatureInCelsius instead of tmp), and proper formatting, whereas less obvious ones are like the example in this article.

Do what you can to automate away anything that would otherwise require unnecessary thinking. Use autoformatters, write unit tests, prefer strong typed languages. Doing so means your own and others' mental capabilities can be spent on actually solving the real problems at hand.

A tired, stressed, future version of you will be thankful - or even better, they'll never have become tired and stressed from unclear code in the first place!

Bonus: () => void

Keen readers who use TypeScript may have noticed I used undefined instead of void when declaring our function type. This is actually to illustrate my point more clearly - though most of the time, void will typically be used instead.

However, a function that returns void can still have its value assigned to. Observe the following example:

Code. Link to code for reading provided just above.
Using void as the return type does not say "this function cannot return", but rather "the return type of this function is, for all intents and purposes, unusable."

Everything you see there is valid TypeScript. Even though functionReturnsVoid is declared as a function that returns void, the definition actually returns the number 5. TypeScript treats this function as if if returns nothing of use, though the underlying JavaScript still executes.