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
- ES2020 Features
- ES2021 Features
- ES2022 Features
- ES2023 Features
- Browser Support and Compatibility
- Migration Strategies
- Best Practices
- Conclusion
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-proneconst city = user && user.address && user.address.city;
// ❌ Throws TypeError if user.address is nullconst city = user.address.city;With optional chaining:
// ✅ Clean and safeconst city = user?.address?.city;
// Returns undefined if any part of the chain is null/undefinedconsole.log(city); // undefined (if user or address is null)Practical examples:
// Accessing array elementsconst firstPost = posts?.[0]?.title;
// Calling methods that might not existconst result = api?.fetchData?.();
// Function calls with optional chainingconst callback = config?.onSuccess;callback?.();
// Works with computed propertiesconst 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 valuesconst count = user.count || 10; // Returns 10 even if count is 0const name = user.name || "Anonymous"; // Returns 'Anonymous' if name is ''const isActive = user.isActive || false; // Always returns falseSolution with ??:
// ✅ Only uses default for null/undefinedconst count = user.count ?? 10; // Uses 0 if count is 0const name = user.name ?? "Anonymous"; // Uses '' if name is ''const isActive = user.isActive ?? false; // Uses false if isActive is falseCombining with optional chaining:
// Powerful combination for safe property access with defaultsconst 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 behaviorconst result = value ?? 10 + 5; // Evaluates as value ?? (10 + 5)
// ✅ Clear intentconst 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 limitationsconst maxSafe = Number.MAX_SAFE_INTEGER; // 9007199254740991const unsafe = maxSafe + 1; // 9007199254740992 (may lose precision)
// BigInt for large integersconst bigNumber = 9007199254740992n; // Note the 'n' suffixconst bigResult = bigNumber + 1n; // 9007199254740993n (precise)
// Creating BigIntconst fromNumber = BigInt(123);const fromString = BigInt("123");const fromHex = BigInt("0x1fffffffffffff");Operations with BigInt:
const a = 10n;const b = 20n;
// Arithmetic operationsconst sum = a + b; // 30nconst product = a * b; // 200nconst division = b / a; // 2n (truncated, no decimals)
// Comparison works with regular numbersconsole.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:
// ❌ TypeErrorconst result = 10n + 5;
// ✅ Explicit conversionconst 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 neededimport { heavyFunction } from "./heavy-module.js";
if (condition) { heavyFunction();}Dynamic import (loaded on demand):
// ✅ Only loaded when neededif (condition) { const { heavyFunction } = await import("./heavy-module.js"); heavyFunction();}
// With error handlingtry { const module = await import("./module.js"); module.default();} catch (error) { console.error("Failed to load module:", error);}Practical use cases:
// Route-based code splittingasync function loadRoute(routeName) { const module = await import(`./routes/${routeName}.js`); return module.default;}
// Feature detectionif ("IntersectionObserver" in window) { const { initObserver } = await import("./observer.js"); initObserver();}
// Conditional polyfillsif (!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 fastconst promises = [ fetch("/api/users"), fetch("/api/posts"), fetch("/api/comments"),];
// ❌ If one fails, all failPromise.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 failuresPromise.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 codeconst 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 everywhereglobalThis.myGlobalVar = "Hello";console.log(globalThis.myGlobalVar); // 'Hello'
// In browser: globalThis === window// In Node.js: globalThis === global// In Web Worker: globalThis === selfES2021 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 (&&=):
// Beforeif (user.name) { user.name = user.name.toUpperCase();}
// With &&=user.name &&= user.name.toUpperCase();
// Only assigns if left side is truthylet x = 1;x &&= 2; // x is now 2
let y = 0;y &&= 2; // y is still 0 (short-circuited)Logical OR assignment (||=):
// Beforeif (!config.theme) { config.theme = "dark";}
// With ||=config.theme ||= "dark";
// Only assigns if left side is falsylet cache = null;cache ||= {}; // cache is now {}
let data = { count: 0 };data.count ||= 10; // data.count is still 0 (0 is falsy)Logical nullish assignment (??=):
// Beforeif (user.settings === null || user.settings === undefined) { user.settings = {};}
// With ??=user.settings ??= {};
// Only assigns if left side is null or undefinedlet 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 looplet result = text;while (result.includes("hello")) { result = result.replace("hello", "hi");}With replaceAll():
// ✅ Simple and clearconst text = "hello world hello";const replaced = text.replaceAll("hello", "hi"); // 'hi world hi'
// Works with stringsconst message = "foo bar foo";message.replaceAll("foo", "baz"); // 'baz bar baz'
// Also works with regexconst 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 readconst billion = 1000000000;const hex = 0xdeadbeef;const binary = 0b1010101010101010;
// With numeric separators: Much clearerconst billion = 1_000_000_000;const hex = 0xdead_beef;const binary = 0b1010_1010_1010_1010;const pi = 3.14159_26535;
// Works in all numeric contextsconst 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 codeconst config = await loadConfig();const data = await fetchData();initializeApp(config, data);
// Export awaited valuesexport const apiKey = await fetch("/api/key").then((r) => r.text());Practical examples:
// Dynamic imports with awaitconst module = await import("./module.js");
// Database connectionconst db = await connectToDatabase();
// Configuration loadingconst config = await fetch("/config.json").then((r) => r.json());
// Error handling still workstry { 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 constructorclass User { constructor(name, email) { this.name = name; this.email = email; this.createdAt = new Date(); }}
// With class fields: Declared in class bodyclass 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 fieldPrivate 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 indicesconst arr = [1, 2, 3, 4, 5];const last = arr[arr.length - 1]; // 5const secondLast = arr[arr.length - 2]; // 4With at():
// ✅ Clean and intuitiveconst arr = [1, 2, 3, 4, 5];const first = arr.at(0); // 1const last = arr.at(-1); // 5const secondLast = arr.at(-2); // 4
// Works with strings tooconst 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.prototypeconst obj = Object.create(null);obj.hasOwnProperty("prop"); // TypeError: obj.hasOwnProperty is not a function
// ❌ Can be overriddenconst malicious = { hasOwnProperty: () => true };malicious.hasOwnProperty("prop"); // Always returns trueSolution with hasOwn():
// ✅ Works with any objectconst obj = Object.create(null);obj.prop = "value";Object.hasOwn(obj, "prop"); // true
// ✅ Safe from overridesconst obj2 = { prop: "value", hasOwnProperty: () => false };Object.hasOwn(obj2, "prop"); // true (not affected by override)
// Standard usageconst user = { name: "John", age: 30 };Object.hasOwn(user, "name"); // trueObject.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 backwardsconst users = [ { id: 1, active: true }, { id: 2, active: false }, { id: 3, active: true },];
// Manual approachlet 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 efficientconst 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 logconst 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.
#!/usr/bin/env nodeconsole.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:
| Feature | Chrome | Firefox | Safari | Node.js |
|---|---|---|---|---|
| Optional Chaining | 80+ | 74+ | 13.1+ | 14.0+ |
| Nullish Coalescing | 80+ | 72+ | 13.1+ | 14.0+ |
| BigInt | 67+ | 68+ | 14+ | 10.4+ |
| Dynamic Import | 63+ | 67+ | 11.1+ | 12.17+ |
| Promise.allSettled | 76+ | 71+ | 13+ | 12.9+ |
| Top-Level Await | 89+ | 89+ | 15+ | 14.8+ |
| Class Fields | 72+ | 69+ | 14+ | 12.0+ |
| Private Fields | 84+ | 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 detectionif (typeof Array.prototype.at === "function") { // Array.at() is supported}
// Using caniuse.com or MDN for detailed compatibilityMigration 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
// Beforeconst value = obj && obj.nested && obj.nested.value;
// Afterconst value = obj?.nested?.value;Pattern 2: Nullish Coalescing Migration
// Beforeconst count = user.count !== null && user.count !== undefined ? user.count : 10;
// Afterconst count = user.count ?? 10;Pattern 3: Promise.allSettled Migration
// Beforeconst results = await Promise.all( promises.map((p) => p.catch((error) => ({ error }))),);
// Afterconst 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 intentconst city = user?.address?.city ?? "Unknown";
// ❌ Overly complex (hard to read)const city = user?.address?.city?.toLowerCase()?.trim() ?? "Unknown";
// ✅ Better: Break into stepsconst 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!