DEV Community

Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

The Settings Object That Came Back undefined

Picture a settings panel that crashes the moment it opens:

TypeError: Cannot read properties of undefined (reading 'theme')
Enter fullscreen mode Exit fullscreen mode

The line that throws is about as boring as code gets:

const settings = getDefaultSettings();
applyTheme(settings.theme); // 💥 settings is undefined
Enter fullscreen mode Exit fullscreen mode

And getDefaultSettings is even more boring. It has no branches, no await, no early exit. It builds an object and returns it:

function getDefaultSettings() {
  return
  {
    theme: "dark"
  };
}
Enter fullscreen mode Exit fullscreen mode

There's an object right there. The function can't possibly return undefined.

It does, every time.


The wrong turn: blaming the caller

When a value shows up as undefined, the natural instinct is to look upstream. Maybe the import is wrong and you're calling a different getDefaultSettings. Maybe something mutates the object after it's returned. Maybe the function is async and someone forgot an await.

Log right at the source:

console.log(getDefaultSettings()); // undefined
Enter fullscreen mode Exit fullscreen mode

No import, no mutation, no promise. The function itself hands back nothing. The bug is somewhere in five lines, and four of them are braces.


The reveal: JavaScript added a semicolon for you

JavaScript lets you leave out most semicolons. To make that work, the parser follows a set of rules called automatic semicolon insertion, or ASI. Most of the time ASI is invisible: it adds a semicolon where you clearly meant one.

But a few statements are treated as finished the moment a line ends. return is one of them. If nothing follows return on the same line, the parser doesn't look at the next line to see if the value is there. It ends the statement on the spot.

How the parser reads that function

1. return ⏎                      — The line ends right after return. For return, a line break counts as the end of the statement
2. insert ;                      — ASI turns it into return; — a return with no value
3. return undefined              — A bare return gives back undefined
4. { theme: 'dark' } never runs  — The braces become a plain code block with a label called theme and the string 'dark' inside it. It sits after a return, so it's dead code
Enter fullscreen mode Exit fullscreen mode

Nothing errors, because { theme: 'dark' } happens to be valid code on its own. It just isn't an object anymore.

That last step is the cruel part. With one property, the "object" is legal as a block with a label. Add a second property and the parser finally complains:

function getDefaultSettings() {
  return
  {
    theme: "dark",
    fontSize: 14
  };
}
// SyntaxError: Unexpected token ':'
Enter fullscreen mode Exit fullscreen mode

So the bug only stays silent while the object is small, which is exactly when nobody bothers to look at it twice.

return isn't alone. The spec calls these restricted productions: places where a line break is not allowed. The ones you'll actually hit:

You write JavaScript reads
return⏎value return; value;
throw⏎new Error() SyntaxError: a line break isn't allowed after throw
break⏎label / continue⏎label break; label;
count⏎++ count; ++… (the ++ attaches to the next line)
(a)⏎=> a SyntaxError: the arrow must be on the same line as its parameters

The mirror image: where ASI won't help

ASI also fails in the opposite direction. It only inserts a semicolon when the next line can't continue the current one. If the next line starts with (, [ or `, it usually can, so no semicolon goes in:

const greeting = "hi"
[1, 2].forEach((n) => console.log(n))
// TypeError: Cannot read properties of undefined (reading 'forEach')
Enter fullscreen mode Exit fullscreen mode

JavaScript reads that as "hi"[1, 2].forEach(...). The comma inside the brackets means "take the last value", so it's "hi"[2], which is undefined.

Same story with an IIFE:

const total = 1
(function () {})()
// TypeError: 1 is not a function
Enter fullscreen mode Exit fullscreen mode

That's 1(function () {})(): calling the number 1 like a function.

ASI adds a semicolon where a line can't continue, never where you meant one.


The fix

Start the value on the same line as return. For a multi-line object, open the brace or a parenthesis right after the keyword:

// before
return
{
  theme: "dark"
};

// after
return {
  theme: "dark"
};

// also fine, handy for long JSX or expressions
return (
  someLongCondition && anotherCondition
);
Enter fullscreen mode Exit fullscreen mode

And let tools catch the rest:

  • Prettier reformats return⏎{ into return; followed by a block, which makes the bug obvious in the diff.
  • ESLint's no-unreachable rule flags the block after the bare return.
  • TypeScript marks it as "Unreachable code detected" in the editor.

If you write without semicolons, the one habit that matters is the mirror case: put a ; in front of any line that starts with (, [ or `.


The rule

Whatever a return, throw, break or continue gives back has to start on the same line as the keyword. The parser doesn't look at the next line.


TL;DR

  • Automatic semicolon insertion ends a return statement at the line break, so return followed by a value on the next line returns undefined.
  • A one-property object after a bare return is still valid code (a labeled block), so nothing errors. Only larger objects throw a SyntaxError.
  • The same rule applies to throw, break, continue, postfix ++ and arrow functions.
  • In the other direction, ASI doesn't add a semicolon before lines starting with (, [ or `, so those lines merge with the line above.
  • Keep the returned value on the return line, and let Prettier, ESLint's no-unreachable or TypeScript flag the rest.

Originally published on Bitlyst.

Top comments (0)