JavaScript has been getting a lot of new, sugary features ever since we got over Harmony, while more features can allow us to write readable, high-quality code it’s also easy to go overboard with what’s new and shiny and run into some of the potential pitfalls.
Let’s go over some of the “gotchas” I see come up quite frequently as sources of confusion, both new and old.
Arrow functions provide a terser and shorter syntax, one of the features available is that you can write your function as a lambda expression with an implicit return value. This comes in handy for functional style code, like when you have to use mapping arrays using a function. That would be quite a few empty lines with regular functions.
For example:
const numbers = [1, 2, 3, 4]; numbers.map(function(n) { return n * n; });
Becomes a sleek easy to read one-liner with the lambda style arrow functions:
const numbers = [1, 2, 3, 4]; numbers.map(n => n * n);
This use case of an arrow function will work as one would expect, it multiplies the values by itself and returns to a new array containing [1, 4, 9, 16]
.
However, if you try mapping into objects however the syntax isn’t what one might intuitively expect it to be, for example, let’s say we’re trying to map our numbers into an array of objects containing the value like this:
const numbers = [1, 2, 3, 4]; numbers.map(n => { value: n });
The result here will actually be an array containing undefined values. While it might look like we are returning an object here, the interpreter sees something completely different. The curly braces are being interpreted as the block scope of the arrow function, the value statement actually ends up being a label. If we were to extrapolate the above arrow function into what the interpreter actually ends up executing, it would look something like this:
const numbers = [1, 2, 3, 4]; numbers.map(function(n) { value: n return; });
The workaround is quite subtle, we just need to wrap the object in parenthesis which turns it into an expression instead of a block statement, like this:
const numbers = [1, 2, 3, 4]; numbers.map(n => ({ value: n }));
Will evaluate to an array containing an array of objects with the values as one would expect.
Another caveat with arrow functions is that they don’t have their own this
binding, meaning their this
value will be the same one as the this
value of the enclosing lexical scope.
So despite the syntax being arguably “sleeker” arrow functions are not a replacement for good ‘ol functions. You can quickly run into situations where your this
binding is not what you thought it was.
For example:
let calculator = { value: 0, add: (values) => { this.value = values.reduce((a, v) => a + v, this.value); }, }; calculator.add([1, 2, 3]); console.log(calculator.value);
While one might expect the this
binding here to be the calculator object there, it will actually result in this
being either undefined or the global object depending on if the code is running in strict mode or not. This is because the closest lexical scope here is the global scope, in strict mode that is undefined, otherwise, it’s the window object in browsers (or the process object in a Node.js compatible environment).
Regular functions do have a this
binding, when called on an object this will point at the object so using a regular function is still the way to go for member functions.
let calculator = { value: 0, add(values) { this.value = values.reduce((a, v) => a + v, this.value); }, }; calculator.add([10, 10]); console.log(calculator.value);
Also, since an arrow function has no this
binding Function.prototype.call, Function.prototype.bind and Function.prototype.apply won’t work with them either. The this
binding is set in stone when the arrow function was declared and can’t change.
So in the following example will run into the same issue as we had earlier, the this
binding is the global object when the adder’s add function is called despite our attempt to override it with Function.prototype.call:
const adder = { add: (values) => { this.value = values.reduce((a, v) => a + v, this.value); }, }; let calculator = { value: 0 }; adder.call(calculator, [1, 2, 3]);
Arrow functions are neat, but they can’t replace regular member functions where a this binding is needed.
While it’s not a new feature, automatic semicolon insertion (ASI) is one of the weirder features in JavaScript so it’s worth a mention. In theory, you can omit semicolons most of the time (which many projects do). If the project has a precedent you should follow that but, you do however need to be aware that ASI is a feature or you’ll end up having code that can be deceiving.
Take the following example:
return { value: 42 }
One might think it would return the object literal, but it will actually return undefined because semicolon insertion takes place making it an empty return statement, followed by a block statement and a label statement.
In other words, the final code that is actually being interpreted looks more like the following:
return; { value: 42 };
As a rule of thumb, never start a line with an opening brace, bracket or template string literal even when using semicolons because ASI always takes place.
Sets are shallow, meaning duplicate arrays and objects with the same values which will lead to multiple entries in the set.
For example:
let set = new Set(); set.add([1, 2, 3]); set.add([1, 2, 3]); console.log(set.length);
The size of that set will be two, which makes sense if you think of it in terms of references as they are different objects.
Strings are immutable, however, so multiple strings in a set like this:
let set = new Set(); set.add([1, 2, 3].join(',')); set.add([1, 2, 3].join(',')); console.log(set.size);
Will end up with the set having a size of one because strings are immutable and interned in JavaScript which can be used as a workaround if you find yourself needing to store a set of objects one could serialize and de-serialize them instead.
In JavaScript, regular functions get hoisted to the top of the lexical scope, meaning the example below will work as one might expect:
let segment = new Segment(); function Segment() { this.x = 0; this.y = 0; }
But the same is not true for classes, classes are actually not hoisted and need to be fully defined in the lexical scope before you attempt to use them.
For example:
let segment = new Segment(); class Segment { constructor() { this.x = 0; this.y = 0; } }
Will result in a ReferenceError when trying to construct a new instance of the class because they aren’t hoisted like functions are.
Finally is a bit of a special case, take a look at the following snippet:
try { return true; } finally { return false; }
What value would you think it returns? The answer is both intuitive and, at the same time, can become unintuitive. One could think the first return statement makes the function actually return and pop the call stack, but this is the exception to that rule because finally statements are always run so the return statement inside the finally block returns instead.
JavaScript is easy to learn but hard to master, in other words, it’s error-prone unless a developer is careful about what and why they’re doing something.
This is especially true with ECMAScript 6 and its sugary features, arrow functions, in particular, come up all of the time. If I was to make a guess I’d say it’s because developers see them as being prettier than regular functions, but they’re not regular functions and they can’t replace them.
Skimming the specification from time to time doesn’t hurt. It’s not the most exciting document in the world but as far as specifications go it’s not THAT bad.
Tools like the AST Explorer also help in shedding some light in what is going on in some of these corner cases, humans and computers tend to parse things differently.
With that said, I’ll leave you with this final example as an exercise.
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.