Fat arrow syntax in JavaScript 🏹
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.
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.
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.
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.
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
.
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.
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.
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!
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:
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.