Skip to main content

Modern JavaScript Features: ES2020-2024 New Features and When to Use Them

Master modern JavaScript features from ES2020-2024 including optional chaining, nullish coalescing, top-level await, and more. Learn when and how to use each feature effectively.

Table of Contents

Introduction

JavaScript has evolved rapidly over the past few years, with ECMAScript introducing powerful new features annually. From ES2020 to ES2024, we’ve seen game-changing additions like optional chaining, nullish coalescing, top-level await, and many more that make JavaScript development more expressive, safer, and efficient.

These modern features solve real-world problems that developers face daily. Whether you’re dealing with deeply nested objects, handling asynchronous operations, or working with classes, understanding these features will help you write cleaner, more maintainable code.

This comprehensive guide covers all major JavaScript features introduced from ES2020 through ES2024, with practical examples and guidance on when to use each feature. By the end, you’ll understand how these features work, when they’re appropriate, and how to leverage them in your projects.


ES2020 Features

ES2020 (ES11) introduced several features that significantly improved JavaScript’s expressiveness and safety. These features address common pain points in everyday development.

Optional Chaining (?.)

Optional chaining allows you to safely access nested object properties without worrying about whether intermediate properties exist. This eliminates the need for verbose null checks and prevents TypeError exceptions.

Before optional chaining:

// ❌ Verbose and error-prone
const city = user && user.address && user.address.city;
// ❌ Throws TypeError if user.address is null
const city = user.address.city;

With optional chaining:

// ✅ Clean and safe
const city = user?.address?.city;
// Returns undefined if any part of the chain is null/undefined
console.log(city); // undefined (if user or address is null)

Practical examples:

// Accessing array elements
const firstPost = posts?.[0]?.title;
// Calling methods that might not exist
const result = api?.fetchData?.();
// Function calls with optional chaining
const callback = config?.onSuccess;
callback?.();
// Works with computed properties
const prop = "name";
const value = user?.[prop];

💡 Tip: Optional chaining short-circuits evaluation. If user is null or undefined, the entire expression returns undefined without evaluating the rest of the chain.

Nullish Coalescing Operator (??)

The nullish coalescing operator (??) provides a way to supply default values when a variable is null or undefined. Unlike the logical OR operator (||), it only checks for null and undefined, not other falsy values like 0, '', or false.

The problem with ||:

// ❌ Problem: 0, '', false are falsy but valid values
const count = user.count || 10; // Returns 10 even if count is 0
const name = user.name || "Anonymous"; // Returns 'Anonymous' if name is ''
const isActive = user.isActive || false; // Always returns false

Solution with ??:

// ✅ Only uses default for null/undefined
const count = user.count ?? 10; // Uses 0 if count is 0
const name = user.name ?? "Anonymous"; // Uses '' if name is ''
const isActive = user.isActive ?? false; // Uses false if isActive is false

Combining with optional chaining:

// Powerful combination for safe property access with defaults
const city = user?.address?.city ?? "Unknown";
const posts = user?.posts ?? [];
const settings = config?.theme ?? "dark";

⚠️ Important: The nullish coalescing operator has lower precedence than most operators. Use parentheses when combining with other operators:

// ❌ Unexpected behavior
const result = value ?? 10 + 5; // Evaluates as value ?? (10 + 5)
// ✅ Clear intent
const result = (value ?? 10) + 5;

BigInt

BigInt allows you to work with integers larger than Number.MAX_SAFE_INTEGER (2^53 - 1). This is essential for handling large numbers in applications dealing with cryptography, financial calculations, or scientific computing.

// Regular numbers have limitations
const maxSafe = Number.MAX_SAFE_INTEGER; // 9007199254740991
const unsafe = maxSafe + 1; // 9007199254740992 (may lose precision)
// BigInt for large integers
const bigNumber = 9007199254740992n; // Note the 'n' suffix
const bigResult = bigNumber + 1n; // 9007199254740993n (precise)
// Creating BigInt
const fromNumber = BigInt(123);
const fromString = BigInt("123");
const fromHex = BigInt("0x1fffffffffffff");

Operations with BigInt:

const a = 10n;
const b = 20n;
// Arithmetic operations
const sum = a + b; // 30n
const product = a * b; // 200n
const division = b / a; // 2n (truncated, no decimals)
// Comparison works with regular numbers
console.log(10n === 10); // false (different types)
console.log(10n == 10); // true (loose equality)
console.log(10n > 5); // true

⚠️ Limitations: BigInt cannot be mixed with regular numbers in arithmetic operations. You must convert explicitly:

// ❌ TypeError
const result = 10n + 5;
// ✅ Explicit conversion
const result = 10n + BigInt(5);
const result = Number(10n) + 5;

Dynamic Import()

Dynamic imports allow you to load modules conditionally at runtime, enabling code splitting and lazy loading. This is crucial for optimizing bundle sizes and improving initial load times.

Static import (evaluated at module load time):

// ❌ Always loaded, even if not needed
import { heavyFunction } from "./heavy-module.js";
if (condition) {
heavyFunction();
}

Dynamic import (loaded on demand):

// ✅ Only loaded when needed
if (condition) {
const { heavyFunction } = await import("./heavy-module.js");
heavyFunction();
}
// With error handling
try {
const module = await import("./module.js");
module.default();
} catch (error) {
console.error("Failed to load module:", error);
}

Practical use cases:

// Route-based code splitting
async function loadRoute(routeName) {
const module = await import(`./routes/${routeName}.js`);
return module.default;
}
// Feature detection
if ("IntersectionObserver" in window) {
const { initObserver } = await import("./observer.js");
initObserver();
}
// Conditional polyfills
if (!window.fetch) {
await import("whatwg-fetch");
}

Promise.allSettled()

Promise.allSettled() returns a promise that resolves after all input promises have settled (either fulfilled or rejected). Unlike Promise.all(), it doesn’t short-circuit on the first rejection, making it perfect for scenarios where you need all results regardless of failures.

Comparison with Promise.all():

// Promise.all() - fails fast
const promises = [
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/comments"),
];
// ❌ If one fails, all fail
Promise.all(promises)
.then((results) => console.log("All succeeded"))
.catch((error) => console.log("One failed:", error));
// Promise.allSettled() - waits for all
// ✅ Returns results for all, regardless of failures
Promise.allSettled(promises).then((results) => {
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Promise ${index} succeeded:`, result.value);
} else {
console.log(`Promise ${index} failed:`, result.reason);
}
});
});

Practical example:

async function fetchMultipleEndpoints(endpoints) {
const promises = endpoints.map((url) =>
fetch(url).catch((error) => ({ error, url })),
);
const results = await Promise.allSettled(promises);
return results.map((result, index) => {
if (result.status === "fulfilled") {
return { success: true, data: result.value, url: endpoints[index] };
} else {
return { success: false, error: result.reason, url: endpoints[index] };
}
});
}

globalThis

globalThis provides a standardized way to access the global object across all JavaScript environments (browser, Node.js, Web Workers, etc.).

Before globalThis:

// ❌ Environment-specific code
const global = (function () {
if (typeof self !== "undefined") return self;
if (typeof window !== "undefined") return window;
if (typeof global !== "undefined") return global;
throw new Error("Unable to locate global object");
})();

With globalThis:

// ✅ Works everywhere
globalThis.myGlobalVar = "Hello";
console.log(globalThis.myGlobalVar); // 'Hello'
// In browser: globalThis === window
// In Node.js: globalThis === global
// In Web Worker: globalThis === self

ES2021 Features

ES2021 (ES12) introduced logical assignment operators and string improvements that make common patterns more concise.

Logical Assignment Operators

Logical assignment operators combine logical operations with assignment, reducing boilerplate code for common patterns.

Logical AND assignment (&&=):

// Before
if (user.name) {
user.name = user.name.toUpperCase();
}
// With &&=
user.name &&= user.name.toUpperCase();
// Only assigns if left side is truthy
let x = 1;
x &&= 2; // x is now 2
let y = 0;
y &&= 2; // y is still 0 (short-circuited)

Logical OR assignment (||=):

// Before
if (!config.theme) {
config.theme = "dark";
}
// With ||=
config.theme ||= "dark";
// Only assigns if left side is falsy
let cache = null;
cache ||= {}; // cache is now {}
let data = { count: 0 };
data.count ||= 10; // data.count is still 0 (0 is falsy)

Logical nullish assignment (??=):

// Before
if (user.settings === null || user.settings === undefined) {
user.settings = {};
}
// With ??=
user.settings ??= {};
// Only assigns if left side is null or undefined
let config = { theme: null };
config.theme ??= "light"; // config.theme is now 'light'
let data = { count: 0 };
data.count ??= 10; // data.count is still 0 (0 is not nullish)

💡 Tip: Use ??= for default values, ||= for fallback values, and &&= for conditional updates.

String.prototype.replaceAll()

The replaceAll() method replaces all occurrences of a substring in a string, eliminating the need for regex or manual loops.

Before replaceAll():

// ❌ Using regex (can be error-prone)
const text = "hello world hello";
const replaced = text.replace(/hello/g, "hi"); // 'hi world hi'
// ❌ Manual loop
let result = text;
while (result.includes("hello")) {
result = result.replace("hello", "hi");
}

With replaceAll():

// ✅ Simple and clear
const text = "hello world hello";
const replaced = text.replaceAll("hello", "hi"); // 'hi world hi'
// Works with strings
const message = "foo bar foo";
message.replaceAll("foo", "baz"); // 'baz bar baz'
// Also works with regex
const text2 = "hello1 world2 hello3";
text2.replaceAll(/\d/g, "X"); // 'helloX worldX helloX'

⚠️ Important: When using a string pattern, replaceAll() replaces all occurrences. When using a regex, you must include the global flag (g), otherwise it throws a TypeError.

Numeric Separators

Numeric separators improve readability of large numbers by allowing underscores as visual separators.

// Before: Hard to read
const billion = 1000000000;
const hex = 0xdeadbeef;
const binary = 0b1010101010101010;
// With numeric separators: Much clearer
const billion = 1_000_000_000;
const hex = 0xdead_beef;
const binary = 0b1010_1010_1010_1010;
const pi = 3.14159_26535;
// Works in all numeric contexts
const array = [1_000, 2_000, 3_000];
const obj = { value: 1_000_000 };

The underscores are purely visual and don’t affect the numeric value. They’re removed during parsing.


ES2022 Features

ES2022 (ES13) introduced significant improvements to classes, top-level await, and array/object methods.

Top-Level Await

Top-level await allows you to use await at the module level, enabling asynchronous module initialization and simplifying entry point code.

Before top-level await:

// ❌ Required async wrapper
(async () => {
const config = await loadConfig();
const data = await fetchData();
initializeApp(config, data);
})();

With top-level await:

// ✅ Clean module-level code
const config = await loadConfig();
const data = await fetchData();
initializeApp(config, data);
// Export awaited values
export const apiKey = await fetch("/api/key").then((r) => r.text());

Practical examples:

// Dynamic imports with await
const module = await import("./module.js");
// Database connection
const db = await connectToDatabase();
// Configuration loading
const config = await fetch("/config.json").then((r) => r.json());
// Error handling still works
try {
const data = await riskyOperation();
} catch (error) {
console.error("Failed:", error);
}

⚠️ Important: Top-level await makes modules asynchronous. Importing modules with top-level await will wait for the await to resolve before the module is considered loaded.

Class Fields

Class fields allow you to declare instance and static fields directly in the class body, making class definitions more concise and intuitive.

Instance fields:

// Before: Fields in constructor
class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.createdAt = new Date();
}
}
// With class fields: Declared in class body
class User {
name;
email;
createdAt = new Date();
constructor(name, email) {
this.name = name;
this.email = email;
}
}

Static fields:

class ApiClient {
static baseURL = "https://api.example.com";
static version = "1.0";
static instances = [];
constructor() {
ApiClient.instances.push(this);
}
}
console.log(ApiClient.baseURL); // 'https://api.example.com'

Private fields:

class BankAccount {
#balance = 0; // Private field (starts with #)
#accountNumber;
constructor(accountNumber) {
this.#accountNumber = accountNumber;
}
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
// ❌ Cannot access from outside
// account.#balance; // SyntaxError
}
const account = new BankAccount("12345");
account.deposit(100);
console.log(account.getBalance()); // 100
// console.log(account.#balance); // SyntaxError: Private field

Private Methods and Accessors

Private methods and accessors provide true encapsulation for class methods and getters/setters.

class User {
#password;
constructor(username, password) {
this.username = username;
this.#password = password;
}
// Private method
#validatePassword(password) {
return password.length >= 8;
}
// Private getter
get #encryptedPassword() {
return this.#hashPassword(this.#password);
}
// Private setter
set #password(value) {
if (!this.#validatePassword(value)) {
throw new Error('Password too short');
}
this.#password = value;
}
// Public method using private methods
changePassword(oldPassword, newPassword) {
if (oldPassword !== this.#password) {
throw new Error('Invalid password');
}
this.#password = newPassword;
}
#hashPassword(password) {
// Hashing logic
return `hashed_${password}`;
}
}

Array.at() Method

The at() method allows you to access array elements using positive and negative indices, providing a more intuitive way to access elements from the end of an array.

Before at():

// ❌ Verbose for negative indices
const arr = [1, 2, 3, 4, 5];
const last = arr[arr.length - 1]; // 5
const secondLast = arr[arr.length - 2]; // 4

With at():

// ✅ Clean and intuitive
const arr = [1, 2, 3, 4, 5];
const first = arr.at(0); // 1
const last = arr.at(-1); // 5
const secondLast = arr.at(-2); // 4
// Works with strings too
const str = "Hello";
const lastChar = str.at(-1); // 'o'

💡 Tip: at() is particularly useful when working with arrays where you frequently need to access elements from the end.

Object.hasOwn()

Object.hasOwn() is a safer alternative to Object.prototype.hasOwnProperty() that works even when objects don’t have Object.prototype in their prototype chain.

The problem with hasOwnProperty():

// ❌ Can fail with objects without Object.prototype
const obj = Object.create(null);
obj.hasOwnProperty("prop"); // TypeError: obj.hasOwnProperty is not a function
// ❌ Can be overridden
const malicious = { hasOwnProperty: () => true };
malicious.hasOwnProperty("prop"); // Always returns true

Solution with hasOwn():

// ✅ Works with any object
const obj = Object.create(null);
obj.prop = "value";
Object.hasOwn(obj, "prop"); // true
// ✅ Safe from overrides
const obj2 = { prop: "value", hasOwnProperty: () => false };
Object.hasOwn(obj2, "prop"); // true (not affected by override)
// Standard usage
const user = { name: "John", age: 30 };
Object.hasOwn(user, "name"); // true
Object.hasOwn(user, "toString"); // false (inherited property)

ES2023 Features

ES2023 (ES14) introduced array methods for finding elements from the end and hashbang grammar support.

Array findLast() and findLastIndex()

These methods find the last element/index in an array that satisfies a condition, complementing the existing find() and findIndex() methods.

Before findLast():

// ❌ Need to reverse or iterate backwards
const users = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
];
// Manual approach
let lastActive;
for (let i = users.length - 1; i >= 0; i--) {
if (users[i].active) {
lastActive = users[i];
break;
}
}
// Or reverse (creates new array)
const lastActive = [...users].reverse().find((u) => u.active);

With findLast():

// ✅ Simple and efficient
const users = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
];
const lastActive = users.findLast((user) => user.active);
// { id: 3, active: true }
const lastActiveIndex = users.findLastIndex((user) => user.active);
// 2
// Practical example: Find last error in log
const logs = [
{ level: "info", message: "Started" },
{ level: "error", message: "Failed" },
{ level: "info", message: "Completed" },
{ level: "error", message: "Critical" },
];
const lastError = logs.findLast((log) => log.level === "error");
// { level: 'error', message: 'Critical' }

Hashbang Grammar

Hashbang grammar allows JavaScript files to start with #! for executable scripts, improving interoperability with Unix-like systems.

./script.js
#!/usr/bin/env node
console.log("Hello from executable script!");

This is primarily useful for Node.js scripts that need to be executable directly without node script.js.


Browser Support and Compatibility

Understanding browser support is crucial when adopting modern JavaScript features. Here’s a quick reference:

FeatureChromeFirefoxSafariNode.js
Optional Chaining80+74+13.1+14.0+
Nullish Coalescing80+72+13.1+14.0+
BigInt67+68+14+10.4+
Dynamic Import63+67+11.1+12.17+
Promise.allSettled76+71+13+12.9+
Top-Level Await89+89+15+14.8+
Class Fields72+69+14+12.0+
Private Fields84+90+14.1+12.0+
Array.at()92+90+15.4+16.6+
Object.hasOwn()93+92+15.4+16.9+
Array.findLast()97+104+15.4+18.0+

Using Babel for compatibility:

If you need to support older browsers, use Babel with appropriate plugins:

{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["> 1%", "last 2 versions"]
}
}
]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
}

Checking feature support:

// Feature detection
if (typeof Array.prototype.at === "function") {
// Array.at() is supported
}
// Using caniuse.com or MDN for detailed compatibility

Migration Strategies

Migrating existing code to use modern JavaScript features should be done gradually and thoughtfully.

Gradual Adoption

Start with safe features: Begin with features that have broad support and minimal risk:

// Safe to adopt immediately (broad support)
const city = user?.address?.city ?? "Unknown";
const theme = (config.theme ||= "dark");
const lastItem = array.at(-1);

Refactor incrementally: Update code as you touch it, rather than doing a big-bang migration:

// Old code (still works)
function getUserCity(user) {
if (user && user.address && user.address.city) {
return user.address.city;
}
return "Unknown";
}
// New code (when refactoring)
function getUserCity(user) {
return user?.address?.city ?? "Unknown";
}

Code Review Checklist

When reviewing code that uses modern features:

Common Migration Patterns

Pattern 1: Optional Chaining Migration

// Before
const value = obj && obj.nested && obj.nested.value;
// After
const value = obj?.nested?.value;

Pattern 2: Nullish Coalescing Migration

// Before
const count = user.count !== null && user.count !== undefined ? user.count : 10;
// After
const count = user.count ?? 10;

Pattern 3: Promise.allSettled Migration

// Before
const results = await Promise.all(
promises.map((p) => p.catch((error) => ({ error }))),
);
// After
const results = await Promise.allSettled(promises);

Best Practices

When to Use Each Feature

Optional Chaining (?.):

  • ✅ Accessing nested object properties
  • ✅ Calling methods that might not exist
  • ✅ Accessing array elements safely
  • ❌ Don’t use when you know the structure exists (adds unnecessary overhead)

Nullish Coalescing (??):

  • ✅ Providing defaults for null/undefined values
  • ✅ When 0, ”, or false are valid values
  • ❌ Don’t use when you want falsy value fallbacks (use || instead)

Top-Level Await:

  • ✅ Module initialization
  • ✅ Configuration loading
  • ✅ Dynamic imports
  • ❌ Don’t use in libraries that need to be imported synchronously

Private Fields (#):

  • ✅ Encapsulating internal state
  • ✅ Preventing external access to sensitive data
  • ✅ Creating clean public APIs
  • ❌ Don’t use for properties that need to be accessed externally

Array.at():

  • ✅ Accessing elements from the end of arrays
  • ✅ When index might be negative
  • ❌ Don’t use for simple positive indices (use bracket notation)

Performance Considerations

Most modern JavaScript features have minimal performance impact, but be aware of:

Optional Chaining:

  • Slight overhead compared to direct property access
  • Negligible in most cases, but avoid in hot loops if performance is critical

BigInt:

  • Slower than regular numbers for arithmetic
  • Only use when you need numbers larger than Number.MAX_SAFE_INTEGER

Dynamic Imports:

  • Network overhead for loading modules
  • Use for code splitting and lazy loading, not for frequently accessed code

Code Readability

Modern features improve readability, but use them judiciously:

// ✅ Clear intent
const city = user?.address?.city ?? "Unknown";
// ❌ Overly complex (hard to read)
const city = user?.address?.city?.toLowerCase()?.trim() ?? "Unknown";
// ✅ Better: Break into steps
const cityName = user?.address?.city;
const city = cityName ? cityName.toLowerCase().trim() : "Unknown";

Testing Modern Features

Ensure your tests cover edge cases:

describe("Optional Chaining", () => {
it("handles null objects", () => {
const user = null;
expect(user?.address?.city).toBeUndefined();
});
it("handles undefined properties", () => {
const user = {};
expect(user?.address?.city).toBeUndefined();
});
it("works with valid paths", () => {
const user = { address: { city: "New York" } };
expect(user?.address?.city).toBe("New York");
});
});

Conclusion

Modern JavaScript features from ES2020-2024 have significantly improved the language’s expressiveness, safety, and developer experience. Features like optional chaining and nullish coalescing eliminate common sources of bugs, while top-level await and class fields make code more intuitive and maintainable.

The key to successfully adopting these features is understanding when to use each one. Optional chaining is perfect for safe property access, nullish coalescing handles default values correctly, and private fields provide true encapsulation. Each feature solves specific problems, so choose the right tool for the job.

As you incorporate these features into your codebase, remember to:

  • Check browser/Node.js compatibility for your target environments
  • Migrate gradually, updating code as you touch it
  • Write tests that cover edge cases
  • Prioritize readability and maintainability
  • Stay updated with the latest ECMAScript proposals

For deeper dives into related topics, check out our guides on JavaScript Promises and Async/Await, Understanding JavaScript Closures, and JavaScript Event Loop.

The JavaScript ecosystem continues to evolve rapidly. Stay curious, experiment with new features, and always prioritize writing code that’s clear, maintainable, and robust. Happy coding!