← Academy Blog

The long path of JavaScript - from ES6 until today.

According to a Stack Overflow survey, JavaScript was the most popular language among developers in 2023. JavaScript was initially developed for Netscape Navigator - a web browser that was developed in the middle of 1990s - and now is being used in almost every domain of programming - Web Development, Mobile app development, Game development, Machine Learning and many others.

But how did a language which was developed in 10 days by Brendan Eich become so popular? In this article, we will go through the life of JavaScript from ES6, which was released in 2015 and was the second major and the biggest release for the language, until today, the year of 2023. We will see why ES6 was important for the future of JavaScript, how it changed JavaScript and how it has influenced it over time.

What was new and important in ES6?

ES6, also known as ECMAScript 2015, was the second major release for JavaScript after ES5 and was the largest release since the language was first released in 1997. It introduced several new features and syntax improvements that enhanced the language's functionality and made it more efficient.

Some of the key features introduced in ES6 include arrow functions, template literals, destructuring, and classes. These additions allowed developers to write cleaner and more concise code, improving readability and maintainability. The lack of some of these features was one of the main reasons why developers were choosing other languages over JavaScript. Another important point of this release was the further release schedule for JavaScript. According to the new schedule, a new version of JavaScript should be released each year which ensures the technology’s further development.

Introduction of let and const keywords

One of the main features of ES6 was the introduction of the let and const keywords. You might wonder why these keywords were important if we already had var. The main difference between var and new keywords was that var has a global scope, while let and const have a block scope. Another important difference is that the variables declared with var are hoisted to the top of their scope (which is global).

The new keywords helped to solve a common issue with variables being accidentally redefined, which resulted in a big number of bugs. The new let and const keywords are block scoped and they cannot be redefined. While the variable defined with let can be reassigned new values, the ones created with const can only be assigned once.

JavaScript Modules

JavaScript modules were a crucial step in allowing the creation of big JavaScript applications. They allowed separating the code into different files, which would allow creating a cleaner codebase. In the early stages of JavaScript, it was used in only some web applications and only a small amount of JavaScript code was written. The pages were not very interactive and having modules was not necessary.

Over the years, different JavaScript libraries and packages were created and applications became more and more interactive, which required more JavaScript code. The lack of modules made this task harder and required additional packages, such as RequireJS.

JavaScript Classes

Although it was possible to implement class-like functionalities before ES6 using function, it wasn’t very easy to do so. Before ES6, creating classes required updating the prototype object, which is a part of every object in JavaScript.

// Before ES6
function User(name: string, birthYear: number): void {
  this.name = name;

  this.birthYear = birthYear;
}

User.prototype.calculateAge = function (): number {
  return new Date().getFullYear() - this.birthYear;
};

var user = new User('Farid', 2002);

// IDE does not see calculateAge method
console.log(user.calculateAge());

We needed to assign each method to a prototype object. Another disadvantage of this method is the fact that IDE does not see the methods we add to a prototype object. The alternative of the same object using ES6 class would be:

// After ES6
class User {
  private name: string;

  private birthYear: number;

  public constructor(name: string, birthYear: number) {
    this.name = name;
    this.birthYear = birthYear;
  }

  public calculateAge(): number {
    return new Date().getFullYear() - this.birthYear;
  }
}

const user = new User('Farid', 2002);

// IDE sees calculateAge method
console.log(user.calculateAge());

As you can see, the new syntax makes creating classes much easier, faster and clear.

Arrow functions

Arrow functions were another major addition to JavaScript with ES6. These functions allowed developers to write shorter and cleaner function expressions, and they also solved some issues with the this keyword in JavaScript.

In traditional function expressions (using function keyword), the this keyword would refer to the object that called the function. However, arrow functions capture the surrounding this context lexically, meaning that they inherit the this value from their surrounding scope. This eliminates the need for using .bind(), .call(), or .apply() to preserve the this context.

class User {
  public name: string;

  public age: number;

  public constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
    // We have to bind `this` to be able to use its correct value within our method
    this.updateAge = this.updateAge.bind(this);
  }

  public updateAge(age: number) {
    this.age = age;
  }
}

In this example, .bind has to be called in order to have a correct value of this within our method. The same method would not require .bind to be called if the method is created using an arrow function.

class User {
  public name: string;

  public age: number;

  public constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  public updateAge = (age: number) => {
    this.age = age;
  };
}

What has changed since ES6?

Although ES6 was one of the biggest updates of JavaScript, it didn’t fix all the issues. Since ES6, lots of important features were added to JavaScript, which greatly improved the language.

Async Await

The introduction of async functions made it much easier to work with Promises. By implementing asynchronous functions, we can wait for Promises to settle, before proceeding with the rest of the logic. Before the introduction of async functions, the same logic could be implemented by using .then chaining, which would reduce the readability of the code.

Here is an example of fetching the list of users without using async await:

const getUsers = () => {
  fetchUsers()
    .then((response) => {
      return response.json();
    })
    .then((users) => {
      console.log(users);

      return users;
    });
};

And this is how simplified the same piece of code is when using async await syntax:

const getUsers = async () => {
  const response = await fetchUsers();
  const users = await response.json();

  console.log(users);

  return users;
};

Optional chaining

Optional chaining is a powerful feature, especially if we often access nested properties or functions, which are optional. It helps to avoid errors such as Cannot read properties of undefined or Cannot read properties of null by returning undefinedwhen the property is not available.

type Coordinates = {
  lat: number;
  lng: number;
};

type UserLocation = {
  country: string;
  coordinates?: Coordinates;
};

type User = {
  name: string;
  surname: string;
  location?: UserLocation;
};

// Without optional chaining
const getUserCoordinates = (user?: User): Coordinates | null => {
  if (user && user.location && user.location.coordinates) {
    return user.location.coordinates;
  }

  return null;
};

Without optional chaining, we need to check each nested object and make sure that the nested object exists. Optional chaining helps us to avoid unnecessary if checks and use inline checks.

// With optional chaining
const getUserCoordinates = (user?: User): Coordinates | null => {
  return user?.location?.coordinates ?? null;
};

Logical assignment operators

Starting from older versions of JavaScript, we can use different assignments such as += or -=, but the similar assignments did not work for logical checks such as ||, && and ??. The logical assignment operators assign the value on the right to the value on the left if the left value is falsy, truthy or nullish.

Logical OR (||=)

The logical OR operator only assigns the value if the value on the left is falsy. In case it is truthy, its value will not change.

// Logical OR
let profilePictureUrl = '';

profilePictureUrl ||= 'default_url';

console.log(profilePictureUrl); // "default_url"

Logical AND

As opposed to the logical OR operator, the logical AND operator will only assign the value if the value on the left is truthy. This comes handy when we try to assign a value to a nested variable in an object.

// Logical AND
type User = {
  name?: string;
};

const user: User = {};

user.name &&= 'Farid';

Nullish coalescing assignment

Nullish coalescing assignment is very similar to the logical OR assignment, but it will only assign a value if the left part is nullish. In JavaScript, nullish values are null and undefined

// Nullish coalescing assignment
const user: User = {
  name: 'Farid',
};

user.name ??= 'Guest';

Top level await

One of the most significant changes in JavaScript since ES6 is the introduction of top level await. This feature allows you to use the await keyword outside of async functions, at the top level of your code. It has made the handling of async operations more straightforward, especially in module initializations and configurations where async functions were not allowed before.

import fs from 'fs/promises';

const readData = async () => {
  try {
    const content = await fs.readFile('data.txt', 'utf-8');

    return content;
  } catch (error) {
    throw new Error('Could not read file', { cause: error });
  }
};

const content = await readData();

Before, the same code would require using .then callbacks, which would make the code messy and hard to read

import fs from 'fs/promises';

const readData = async () => {
  try {
    const content = await fs.readFile('data.txt', 'utf-8');

    return content;
  } catch (error) {
    throw new Error('Could not read file', { cause: error });
  }
};

readData().then((content) => {
  // handle content further
});

What awaits JavaScript in future

JavaScript is improved day by day and a crucial role in this process is played by TC39 (Technical Committee 39). This committee is responsible for evolving the JavaScript language further, maintaining and updating the language standards, analyzing proposals and other processes that help JavaScript to continuously improve. As mentioned before, TC39 is responsible for analyzing proposals. Proposals are the contributions from the community to add new features to JavaScript. Some of the popular proposals are Temporal, import attributes and pipeline operator.

Temporal

The Temporal API, which is currently in Stage 3, is being developed to improve the current Date object, which is mostly known for its unexpected behavior. Today there are lots of date-time libraries for JavaScript, such as date-fns, moment, js-joda and a huge number of others. They all try to help with unpredictable and unexpected behavior of JavaScript Date object by adding features such as timezones, date parsing and almost everything else.

With different objects like Temporal.TimeZone, Temporal.PlainDate and others, Temporal API is trying to address all these issues and replace JavaScript Date object. You can start testing the new API using an npm package named @js-temporal/polyfill

Import attributes

The proposal for import attributes aims to improve the import statement in JavaScript by allowing developers to assert certain conditions about the imported module. This can help catch errors at compile-time instead of runtime and make the code more robust. Currently this proposal is in Stage 3.

With import attributes, you can specify the type of the imported module or ensure that a specific version of the module is used.

import json from "./foo.json" assert { type: "json" };
import("foo.json", { with: { type: "json" } });

Pipeline operator

The pipeline operator proposal, which is currently in Stage 2, introduces a new operator |> that allows developers to chain multiple function calls together in a more readable and concise way. Together with the pipeline operator, the placeholder operator % is being introduced which will hold the previous function’s value. It should enhance the readability and maintainability of code, especially when performing a series of operations on a value.

Instead of nested function calls or long chains of dot notation, the pipeline operator allows developers to write code in a more linear and intuitive manner. Here is a real-world example from a React repository, which can be improved by using the pipeline operator:

console.log(
  chalk.dim(
    `$ ${Object.keys(envars)
      .map((envar) => `${envar}=${envars[envar]}`)
      .join(' ')}`,
    'node',
    args.join(' '),
  ),
);

As you can see, by adding nested function calls it becomes much harder to read and understand the code. By adding the pipeline operator, this code can be updated and improved:

Object.keys(envars)
  .map(envar => `${envar}=${envars[envar]}`)
  .join(' ')
  |> `$ ${%}`
  |> chalk.dim(%, 'node', args.join(' '))
  |> console.log(%);

Decorators

Although decorators are not yet available in JavaScript, they are actively used with the help of such transpilers as TypeScript, Babel and Webpack. Currently, the decorators proposal is in Stage 3, which means it is getting closer to being a part of native JavaScript. Decorators are the functions, that are called on other JavaScript elements, such as classes, class elements, methods, or other functions, by adding an additional functionality on those elements.

Custom decorators can be easily created and used. In the bottom, the decorator is just a function, that accepts specific arguments:

  • Value - the element, on which the decorator is used
  • Context - The context of the element, on which the decorator is used.

Here’s the total type of the decorator:

type Decorator = (
  value: Input,
  context: {
    kind: string;
    name: string | symbol;
    access: {
      get?(): unknown;
      set?(value: unknown): void;
    };
    private?: boolean;
    static?: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Output | void;

A useful example of using the decorators would be for protecting the methods with a validation rule. For that, we can create the following decorator:

const validateUser = (rule: (...args: number[]) => boolean) => {
  const decorator: Decorator = (_target, _context) => {
    return function (...args: number[]) {
      const isValidated = rule(...args);

      if (!isValidated) {
        throw new Error('Arguments are not validated');
      }
    };
  };

  return decorator;
};

And use it the following way:

const ADMIN_ID = 1;

const checkIsAdmin = (id: unknown): boolean => {
  return id === ADMIN_ID;
};

class User {
  @validateUser(checkIsAdmin)
  deleteUser(id: unknown) {
    // Delete logic
  }
}

These are just a few examples of the proposals that are being considered for the future of JavaScript. With the continuous efforts of TC39 and the active participation of the JavaScript community, we can expect JavaScript to evolve and improve even further in the coming years.

Conclusion

JavaScript has come a long way since the release of ES6 in 2015. ES6 introduced important features such as let and const keywords, JavaScript modules, classes, and arrow functions. Since then, JavaScript has continued to improve, with the introduction of features like async/await, optional chaining, and logical assignment operators.

Looking ahead, JavaScript has a promising future with proposals like Temporal, import attributes, the pipeline operator, and decorators. With the continuous efforts of TC39 and the active participation of the JavaScript community, we can expect JavaScript to continue improving and remain a popular language in the years to come.