JavaScript best practices to improve code quality
Learn how some of the new features in JavaScript can help you write cleaner code.
- By Dhruv
- ·
- Insights
- JavaScript
- Code Review
If you write JavaScript today, it's worth your time staying in the know of all the updates the language has seen in the past few years. Since 2015, with the release of ES6, a new version of the ECMAScript spec has been released each year. Each iteration adds new features, new syntax, and Quality of Life improvements to the language. JavaScript engines in most browsers and Node.js quickly catch up, and it's only fair that your code should catch up as well. That's because with each new iteration of JavaScript comes new idioms and new ways to express your code, and many a time, these changes may make the code more maintainable for you and your collaborators.
Here are some of the latest ECMAScript features, and by induction, JavaScript and Node.js that you can make use of to write cleaner, more concise, and more readable code.
1. Block scored declarations
Since the inception of the language, JavaScript developers have used var
to declare variables. The keyword var
has its quirks, the most problematic of those being the scope of the variables created by using it.
var x = 10
if (true) {
var x = 15 // inner declaration overrides declaration in parent scope
console.log(x) // prints 15
}
console.log(x) // prints 15
Since variables defined with var
are not block-scoped, redefining them in a narrower scope affects the value of the outer scope.
Now we have two new keywords that replace var
, namely let
and const
that do not suffer from this drawback.
let y = 10
if (true) {
let y = 15 // inner declaration is scoped within the if block
console.log(y) // prints 15
}
console.log(y) // prints 10
const
and let
differ in the semantics that variables declared with const
cannot be reassigned in their scope. This does not mean they are immutable, only that their references cannot be changed.
const x = []
x.push('Hello', 'World!')
x // ["Hello", "World!"]
x = [] // TypeError: Attempted to assign to readonly property.
2. Arrow functions
Arrow functions are another very important feature introduced recently to JavaScript. They come bearing many advantages. First and foremost, they make the functional aspects of JavaScript beautiful to look at and simpler to write.
let x = [1, 2, 3, 4]
x.map((val) => val * 2) // [2, 4, 6, 8]
x.filter((val) => val % 2 == 0) // [2, 4]
x.reduce((acc, val) => acc + val, 0) // 10
In all of the above examples the arrow functions, named after the distinctive arrow =>
, replace traditional functions with a concise syntax.
- If the function body is a single expression, the scope brackets
{}
andreturn
keyword are implied and need not be written. - If the function has a single argument, the argument parentheses
()
are implied and need not be written. - If the function body expression is a dictionary, it must be enclosed in parentheses
()
.
Another significant advantage of arrow functions is that they do not define a scope but rather exist within the parent scope. This avoids a lot of pitfalls that can arise with the use of the this
keyword. Arrow functions have no bindings for this
. Inside the arrow function, the value of this
is the same as that in the parent scope. Consequently, arrow functions cannot be used as methods or constructors. Arrow functions don't work with apply
, bind
, or call
and have no bindings for super
.
They also have certain other limitations such as lack of the arguments
object which traditional functions can access and the inability to yield
from the function body.
Thus arrow functions are not a 1:1 replacement for standard functions but welcome addition to JavaScript's feature set.
3. Optional chaining
Imagine a deeply nested data structure like this person
object here. Consider you wanted to access the first and last name of this person. You would write this in JavaScript like so:
person = {
name: {
first: 'John',
last: 'Doe'
},
age: 42
}
person.name.first // 'John'
person.name.last // 'Doe'
Now imagine what would happen if the person
object did not contain a nested name
object.
person = {
age: 42
}
person.name.first // TypeError: Cannot read property 'first' of undefined
person.name.last // TypeError: Cannot read property 'last' of undefined
To avoid such errors, developers had to resort to code like the following, which is unnecessarily verbose, hard to read, and unpleasant to write — a very bad trio of adjectives.
person && person.name && person.name.first // undefined
Meet optional chaining, a new feature of JavaScript that does away with this monstrosity. Optional chaining short-circuits the digging process as soon as it encounters a null
or undefined
value and returns undefined
without raising an error.
person?.name?.first // undefined
The resultant code is much concise and cleaner.
4. Null-ish coalescing
Before introducing the null-ish coalescing operator, JavaScript developers used the OR operator ||
to fall back to a default value if the input was absent. This came with a significant caveat that even legitimate but falsy values would result in a fallback to the defaults.
function print(val) {
return val || 'Missing'
}
print(undefined) // 'Missing'
print(null) // 'Missing'
print(0) // 'Missing'
print('') // 'Missing'
print(false) // 'Missing'
print(NaN) // 'Missing'
JavaScript has now proposed the null coalescing operator ??
, which offers a better alternative in that it only results in a fallback if the preceding expression is null-ish. Here null-ish refers to values that are null
or undefined
.
function print(val) {
return val ?? 'Missing'
}
print(undefined) // 'Missing'
print(null) // 'Missing'
print(0) // 0
print('') // ''
print(false) // false
print(NaN) // NaN
This way, you can ensure that if your program accepts falsy values as legitimate inputs, you won't end up replacing them with fallbacks.
5. Logical assignment
Let's say you want to assign a value to a variable if and only if the value is currently null-ish. A logical way to write this would be like so:
if (x === null || x == undefined) {
x = y
}
If you knew about how short-circuiting works, you might want to replace those 3 lines of code with a more succinct version using the null-ish coalescing operator.
x ?? (x = y) // x = y if x is nullish, else no effect
Here we use the short-circuiting feature of the null-ish coalescing operator to execute the second part x = y
if x
is null-ish. The code is pretty concise, but it still is not very easy to read or understand. The logical null-ish assignment does away with the need for such a workaround.
x ??= y // x = y if x is nullish, else no effect
Along the same lines, JavaScript also introduces logical AND assignment &&=
and logical OR assignment ||=
operators. These operators perform assignment only when the specific condition is met and have no effect otherwise.
x ||= y // x = y if x is falsy, else no effect
x &&= y // x = y if x is truthy, else no effect
Pro-tip: If you've written Ruby before, you've seen the ||=
and &&=
operators, since Ruby does not have the concept of falsy values.
6. Named capture groups
Let's start with a quick recap of capture groups in regular expressions. A capture group is a part of the string that matches a portion of regex in parentheses.
let re = /(\d{4})-(\d{2})-(\d{2})/
let result = re.exec('Pi day this year falls on 2021-03-14!')
result[0] // '2020-03-14', the complete match
result[1] // '2020', the first capture group
result[2] // '03', the second capture group
result[3] // '14', the third capture group
Regular expressions have also supported named capture groups for quite some time, which is a way for the capture groups to be referenced by a name rather than an index. Now, with ES9, this feature has made its way to JavaScript. Now the result object contains a nested groups object where each capture group's value is mapped to its name.
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
let result = re.exec('Pi day this year falls on 2021-03-14!')
result.groups.year // '2020', the group named 'year'
result.groups.month // '03', the group named 'month'
result.groups.day // '14', the group named 'day'
The new API works beautifully with another new JavaScript feature, de-structured assignments.
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
let result = re.exec('Pi day this year falls on 2021-03-14!')
let { year, month, day } = result.groups
year // '2020'
month // '03'
day // '14'
7. async
& await
One of the powerful aspects of JavaScript is its asynchronicity. This means that many functions that may be long-running or time-consuming can return a Promise and not block execution.
const url = 'https://the-one-api.dev/v2/book'
let prom = fetch(url)
prom // Promise {<pending>}
// wait a bit
prom // Promise {<fullfilled>: Response}, if no errors
// or
prom // Promise {<rejected>: Error message}, if any error
Here the call to fetch returns a Promise that has the status 'pending' when created. Soon, when the API returns the response, it transitions into a 'fulfilled' state, and the Response that it wraps can be accessed. In the Promises world, you would do something like this to make an API call and parse the response as JSON.
const url = 'https://the-one-api.dev/v2/book'
let prom = fetch(url)
prom // Promise {<fullfilled>: Response}
.then((res) => res.json())
.then((json) => console.log(json)) // prints response, if no errors
.catch((err) => console.log(err)) // prints error message, if any error
In 2017, JavaScript announced two new keywords async
and await
, that make handling and working with Promises easier and more fluent. They are not a replacement for Promises; they are merely syntactic sugar on top of the powerful Promises concepts.
Instead of all the code happening inside a series of 'then' functions, await
makes it all look like synchronous JavaScript. As an added benefit, you can use try...catch
with await
instead of handling errors in 'catch' functions as you would have to if consuming Promises directly. The same code with await
would look like this.
const url = 'https://the-one-api.dev/v2/book'
let res = await fetch(url) // Promise {<fullfilled>: Response} -await-> Response
try {
let json = await res.json()
console.log(json) // prints response, if no errors
} catch (err) {
console.log(err) // prints error message, if any error
}
The async
keyword is the other side of the same coin, in that it wraps any data to be sent within a Promise. Consider the following asynchronous function for adding several numbers. In the real world, your code would be doing something much more complicated.
async function sum(...nums) {
return nums.reduce((agg, val) => agg + val, 0)
}
sum(1, 2, 3) // Promise {<fulfilled>: 6}
.then(res => console.log(res) // prints 6
let res = await sum(1, 2, 3) // Promise {<fulfilled>: 6} -await-> 6
console.log(res) // prints 6
These new features just the tip of the iceberg. We have barely even scratched the surface. JavaScript is constantly evolving, and new features are added to the language every year. It's tough to keep up with the constant barrage of new features and idioms introduced to the language manually.
Wouldn't it be nice if some tool could handle this for us? Fret not, there is. We've already talked in detail about setting up static code analysis in your JavaScript repo using ESLint. It's extremely useful and should be an indispensable tool of your toolchain. But to be honest, setting up ESLint auto-fix pipelines and processes takes time and effort. Unless you enjoy this sort of plumbing, you'd be better off if you wrote the code and outsourced the plumbing to...DeepSource!
DeepSource can help you with automating the code reviews and save you a ton of time. Just add a .deepsource.toml
file in the root of the repository and DeepSource will pick it up for scanning right away. The scan will find scope for improvements across your code and help you fix them with helpful descriptions.
Sign up and see for yourself!