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
Last updated Apr 8, 2021

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.

  1. If the function body is a single expression, the scope brackets {} and return keyword are implied and need not be written.
  2. If the function has a single argument, the argument parentheses () are implied and need not be written.
  3. 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!

Ship clean and secure code.