
Introduction
JavaScript evolves quickly, and new ECMAScript releases often introduce features that quietly improve code quality, safety, and readability. While many developers focus on headline features like async/await or optional chaining, several smaller additions deliver real-world benefits and are easy to miss. Since ES2020, JavaScript has added numerous features that can eliminate boilerplate code, prevent common bugs, and make your intentions clearer to other developers. In this comprehensive guide, you will discover modern ECMAScript features from ES2020 through ES2024 that are already available in today’s runtimes, understand how they work with practical examples, and learn when to use them effectively. By the end, you will be able to write cleaner, safer, and more expressive JavaScript with minimal effort.
Why Keeping Up with ECMAScript Matters
JavaScript runs everywhere, from browsers to servers, mobile apps, and edge platforms. Because of this reach, even small language improvements can have a large impact on developer experience and long-term maintainability.
• Cleaner syntax reduces boilerplate and cognitive load
• New APIs improve safety and correctness
• Better defaults lead to fewer runtime bugs
• Modern features often replace custom utility libraries
• Updated codebases are easier to read and maintain
• Hiring becomes easier when code uses familiar patterns
• Performance often improves with native implementations
Staying current helps you write code that is both future-proof and easier for teams to understand.
Optional Chaining Beyond the Basics
Most developers know optional chaining (?.) for safe property access. However, it also works with method calls, dynamic keys, and array indexing.
// Basic property access (well known)
const name = user?.profile?.name;
// Method calls - only calls if method exists
const avatar = user.profile?.getAvatar?.();
// Dynamic property access
const key = 'email';
const email = settings?.[key];
// Array indexing
const firstItem = items?.[0]?.name;
// Combined with nullish coalescing
const displayName = user?.profile?.name ?? 'Anonymous';
// Practical example: API response handling
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return {
name: data?.user?.profile?.displayName ?? 'Unknown',
email: data?.user?.email ?? null,
avatar: data?.user?.profile?.avatar?.url ?? '/default-avatar.png',
permissions: data?.user?.roles?.map?.(r => r.name) ?? []
};
} catch (error) {
return null;
}
}
As a result, you can avoid defensive checks without adding noise to your code.
Nullish Coalescing and Assignment Operators
ECMAScript introduced assignment variants that combine nullish checks with assignment. These operators only apply when the value is null or undefined, not when it is falsy (like 0, '', or false).
// Nullish coalescing assignment (??=)
// Only assigns if left side is null or undefined
let config = { timeout: 0, retries: null };
config.timeout ??= 5000; // Stays 0 (0 is not nullish)
config.retries ??= 3; // Becomes 3 (null is nullish)
config.debug ??= false; // Becomes false (undefined is nullish)
console.log(config); // { timeout: 0, retries: 3, debug: false }
// Logical OR assignment (||=)
// Assigns if left side is falsy
let settings = { port: 0, host: '' };
settings.port ||= 3000; // Becomes 3000 (0 is falsy)
settings.host ||= 'localhost'; // Becomes 'localhost' ('' is falsy)
// Logical AND assignment (&&=)
// Assigns only if left side is truthy
let user = { name: 'Alice', verified: true };
user.verified &&= checkStillValid(); // Only calls function if verified is true
user.name &&= sanitize(user.name); // Only sanitizes if name exists
// Real-world configuration example
function initializeApp(options = {}) {
// Set defaults only where not provided
options.port ??= process.env.PORT ?? 3000;
options.host ??= process.env.HOST ?? '0.0.0.0';
options.maxConnections ??= 100;
options.timeout ??= 30000;
// Apply production settings if in production mode
options.cache &&= !options.debug;
options.minify &&= process.env.NODE_ENV === 'production';
return options;
}
These operators reduce repetitive conditionals and make intent clearer.
Numeric Separators for Readability
Large numbers become much easier to read with numeric separators. The underscore has no effect on the value but dramatically improves human readability.
// Without separators (hard to count zeros)
const maxFileSize = 10000000;
const oneDay = 86400000;
const budget = 1500000000;
// With separators (immediately clear)
const maxFileSizeReadable = 10_000_000; // 10 million bytes
const oneDayReadable = 86_400_000; // milliseconds
const budgetReadable = 1_500_000_000; // 1.5 billion
// Works with different bases
const hexColor = 0xFF_EC_80; // RGB components visible
const binary = 0b1010_0001_1000_0101; // 4-bit groups
const octal = 0o7_5_5; // Unix permissions
// Works with BigInt
const bigNumber = 9_007_199_254_740_993n;
// Works with decimals
const pi = 3.141_592_653;
// Practical example: Configuration with time constants
const TIME_CONSTANTS = {
SECOND: 1_000,
MINUTE: 60_000,
HOUR: 3_600_000,
DAY: 86_400_000,
WEEK: 604_800_000,
};
const SIZE_CONSTANTS = {
KB: 1_024,
MB: 1_048_576,
GB: 1_073_741_824,
TB: 1_099_511_627_776,
};
// Much clearer in conditionals
if (fileSize > 50_000_000) {
console.log('File exceeds 50MB limit');
}
This feature improves clarity without changing behavior and works in both JavaScript and TypeScript.
Top-Level Await
Modern JavaScript supports await at the top level of ES modules. This removes the need for immediately-invoked async functions or wrapper functions.
// Before: Required wrapper function
(async () => {
const response = await fetch('/api/config');
const config = await response.json();
startApp(config);
})();
// After: Top-level await (ES Modules only)
const response = await fetch('/api/config');
const config = await response.json();
export { config };
// Practical example: Database connection module
// db.js
import { createConnection } from './database-driver.js';
const connection = await createConnection({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
});
// Wait for connection to be ready
await connection.ping();
console.log('Database connected');
export { connection };
// app.js - connection is guaranteed to be ready
import { connection } from './db.js';
const users = await connection.query('SELECT * FROM users');
// Dynamic imports with top-level await
const locale = navigator.language.split('-')[0];
const messages = await import(`./i18n/${locale}.js`);
// Conditional module loading
let adapter;
if (process.env.NODE_ENV === 'production') {
adapter = await import('./adapters/production.js');
} else {
adapter = await import('./adapters/development.js');
}
export const { send, receive } = adapter;
This feature is especially helpful in scripts, configuration files, and server startup logic.
Private Class Fields and Methods
ECMAScript added true private fields and methods to classes using the # syntax. Unlike naming conventions like _private, these fields are enforced by the language itself and cannot be accessed from outside the class.
class BankAccount {
// Private fields
#balance = 0;
#transactionHistory = [];
#accountNumber;
// Public field
ownerName;
constructor(ownerName, accountNumber, initialDeposit = 0) {
this.ownerName = ownerName;
this.#accountNumber = accountNumber;
this.#balance = initialDeposit;
this.#logTransaction('INITIAL_DEPOSIT', initialDeposit);
}
// Private method
#logTransaction(type, amount) {
this.#transactionHistory.push({
type,
amount,
balance: this.#balance,
timestamp: new Date().toISOString()
});
}
#validateAmount(amount) {
if (typeof amount !== 'number' || amount <= 0) {
throw new Error('Amount must be a positive number');
}
}
// Public methods that use private members
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
this.#logTransaction('DEPOSIT', amount);
return this.#balance;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) {
throw new Error('Insufficient funds');
}
this.#balance -= amount;
this.#logTransaction('WITHDRAWAL', -amount);
return this.#balance;
}
// Getter for private field (controlled access)
get balance() {
return this.#balance;
}
// Cannot directly set balance from outside
getStatement() {
return [...this.#transactionHistory]; // Return copy
}
// Private static field
static #instances = 0;
static getInstanceCount() {
return BankAccount.#instances;
}
}
const account = new BankAccount('Alice', '12345', 1000);
account.deposit(500);
console.log(account.balance); // 1500 (via getter)
console.log(account.#balance); // SyntaxError: Private field
aconsole.log(account.#logTransaction); // SyntaxError: Private method
Static Class Blocks
Static blocks allow complex initialization logic to live directly inside a class definition, running once when the class is evaluated.
class Configuration {
static settings;
static #privateSettings;
static isInitialized = false;
// Static block - runs when class is defined
static {
console.log('Initializing Configuration class...');
try {
// Complex initialization logic
const env = process.env.NODE_ENV || 'development';
const baseConfig = {
apiVersion: 'v2',
timeout: 30000,
retries: 3
};
const envConfig = {
development: {
apiUrl: 'http://localhost:3000',
debug: true,
logLevel: 'debug'
},
production: {
apiUrl: 'https://api.example.com',
debug: false,
logLevel: 'error'
}
};
this.settings = { ...baseConfig, ...envConfig[env] };
this.#privateSettings = { secretKey: process.env.SECRET_KEY };
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize Configuration:', error);
this.settings = {};
}
}
static getSecret() {
return this.#privateSettings?.secretKey;
}
}
// Multiple static blocks are allowed
class Database {
static pool;
static #connectionString;
static {
// First block: Build connection string
const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS } = process.env;
this.#connectionString = `postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}`;
}
static {
// Second block: Initialize pool
this.pool = new Pool({
connectionString: this.#connectionString,
max: 20,
idleTimeoutMillis: 30000
});
}
}
Object.hasOwn()
Instead of relying on hasOwnProperty, modern JavaScript provides a safer alternative that works even when the prototype chain has been modified.
// The old way - can break in edge cases
const obj = { key: 'value' };
obj.hasOwnProperty('key'); // true - works normally
// But what if hasOwnProperty is overridden?
const dangerous = {
hasOwnProperty: () => false, // Overridden!
key: 'value'
};
dangerous.hasOwnProperty('key'); // false - WRONG!
// Workaround using call
Object.prototype.hasOwnProperty.call(dangerous, 'key'); // true - verbose
// The new way - always safe
Object.hasOwn(dangerous, 'key'); // true - correct!
// Works with Object.create(null)
const nullProto = Object.create(null);
nullProto.key = 'value';
// nullProto.hasOwnProperty('key'); // TypeError: not a function
Object.hasOwn(nullProto, 'key'); // true - works!
// Practical example: Safe property iteration
function sanitizeInput(input) {
const sanitized = {};
const allowedFields = ['name', 'email', 'age'];
for (const field of allowedFields) {
if (Object.hasOwn(input, field)) {
sanitized[field] = input[field];
}
}
return sanitized;
}
// Checking for own vs inherited properties
class Animal {
species = 'unknown';
}
class Dog extends Animal {
breed = 'mixed';
}
const dog = new Dog();
Object.hasOwn(dog, 'breed'); // true - own property
Object.hasOwn(dog, 'species'); // true - own property (from class field)
'breed' in dog; // true - includes prototype chain
Array.at() for Safer Indexing
The at() method simplifies access to array elements, especially when accessing from the end of an array.
const items = ['first', 'second', 'third', 'fourth', 'last'];
// The old way - verbose for negative indexing
const lastOld = items[items.length - 1]; // 'last'
const secondLastOld = items[items.length - 2]; // 'fourth'
// The new way - clean and intuitive
const last = items.at(-1); // 'last'
const secondLast = items.at(-2); // 'fourth'
const first = items.at(0); // 'first'
// Works with strings too
const str = 'Hello World';
str.at(-1); // 'd'
str.at(0); // 'H'
// Works with TypedArrays
const buffer = new Uint8Array([10, 20, 30, 40]);
buffer.at(-1); // 40
// Practical example: Breadcrumb navigation
function getCurrentPage(breadcrumbs) {
return breadcrumbs.at(-1)?.name ?? 'Home';
}
function getParentPage(breadcrumbs) {
return breadcrumbs.at(-2)?.name ?? null;
}
// Practical example: Recent items
function getRecentItems(items, count = 5) {
const recent = [];
for (let i = 1; i <= Math.min(count, items.length); i++) {
recent.push(items.at(-i));
}
return recent;
}
// Chaining with other methods
const names = ['Alice', 'Bob', 'Charlie', 'Diana'];
const lastUppercase = names.at(-1).toUpperCase(); // 'DIANA'
const lastSorted = names.sort().at(-1); // 'Diana' (alphabetically last)
Array.findLast() and findLastIndex()
These methods search from the end of an array, which is more efficient when you know the item is near the end.
const transactions = [
{ id: 1, type: 'deposit', amount: 100 },
{ id: 2, type: 'withdrawal', amount: 50 },
{ id: 3, type: 'deposit', amount: 200 },
{ id: 4, type: 'withdrawal', amount: 75 },
{ id: 5, type: 'deposit', amount: 150 }
];
// Find last deposit (searches from end)
const lastDeposit = transactions.findLast(t => t.type === 'deposit');
// { id: 5, type: 'deposit', amount: 150 }
const lastDepositIndex = transactions.findLastIndex(t => t.type === 'deposit');
// 4
// Before: Had to reverse or iterate manually
const lastDepositOld = [...transactions].reverse().find(t => t.type === 'deposit');
// Works but creates copy and reverses entire array
// Practical example: Finding most recent error in logs
const logs = [
{ level: 'info', message: 'Server started', timestamp: '10:00' },
{ level: 'error', message: 'Connection failed', timestamp: '10:05' },
{ level: 'info', message: 'Retry successful', timestamp: '10:06' },
{ level: 'warn', message: 'High memory usage', timestamp: '10:30' },
{ level: 'error', message: 'Timeout error', timestamp: '10:45' },
{ level: 'info', message: 'Request completed', timestamp: '10:46' }
];
const mostRecentError = logs.findLast(log => log.level === 'error');
// { level: 'error', message: 'Timeout error', timestamp: '10:45' }
// Finding the version where a breaking change was introduced
const versions = [
{ version: '1.0.0', breaking: false },
{ version: '1.1.0', breaking: false },
{ version: '2.0.0', breaking: true },
{ version: '2.1.0', breaking: false },
{ version: '3.0.0', breaking: true }
];
const lastBreaking = versions.findLast(v => v.breaking);
// { version: '3.0.0', breaking: true }
structuredClone() for Deep Copying
The structuredClone() function provides a native way to deep-copy objects without the limitations of JSON serialization.
// The old ways and their problems
const original = {
date: new Date(),
regex: /pattern/gi,
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
nested: { deep: { value: 42 } },
array: [1, [2, [3]]]
};
// JSON method loses types
const jsonCopy = JSON.parse(JSON.stringify(original));
// jsonCopy.date is a string, not Date!
// jsonCopy.regex is empty object {}
// jsonCopy.map is empty object {}
// jsonCopy.set is empty object {}
// Spread only does shallow copy
const spreadCopy = { ...original };
spreadCopy.nested.deep.value = 100;
console.log(original.nested.deep.value); // 100 - MUTATED!
// structuredClone does true deep copy with type preservation
const properCopy = structuredClone(original);
properCopy.nested.deep.value = 100;
console.log(original.nested.deep.value); // 42 - unchanged
console.log(properCopy.date instanceof Date); // true
console.log(properCopy.regex instanceof RegExp); // true
console.log(properCopy.map instanceof Map); // true
console.log(properCopy.set instanceof Set); // true
// Supported types include:
// - All primitive types
// - Date, RegExp, Map, Set
// - ArrayBuffer, DataView, TypedArrays
// - Blob, File, FileList, ImageData
// - Nested objects and arrays
// NOT supported (throws error):
// - Functions
// - DOM nodes
// - Symbols (as keys)
// - Property descriptors, getters, setters
// - Prototype chain
// Practical example: State management
function createStore(initialState) {
let state = structuredClone(initialState);
const listeners = new Set();
return {
getState() {
return structuredClone(state); // Return copy to prevent mutation
},
setState(updater) {
const newState = updater(structuredClone(state));
state = structuredClone(newState);
listeners.forEach(listener => listener(this.getState()));
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}
Array toSorted(), toReversed(), and toSpliced()
These immutable array methods return new arrays instead of modifying the original, making them perfect for functional programming patterns.
const numbers = [3, 1, 4, 1, 5, 9, 2, 6];
// Old mutating methods
const sorted = [...numbers].sort((a, b) => a - b); // Must copy first
const reversed = [...numbers].reverse(); // Must copy first
// New immutable methods
const sortedNew = numbers.toSorted((a, b) => a - b);
console.log(numbers); // [3, 1, 4, 1, 5, 9, 2, 6] - unchanged!
console.log(sortedNew); // [1, 1, 2, 3, 4, 5, 6, 9]
const reversedNew = numbers.toReversed();
console.log(numbers); // [3, 1, 4, 1, 5, 9, 2, 6] - unchanged!
console.log(reversedNew); // [6, 2, 9, 5, 1, 4, 1, 3]
// toSpliced - immutable splice
const letters = ['a', 'b', 'c', 'd', 'e'];
const spliced = letters.toSpliced(2, 1, 'X', 'Y'); // Remove 1 at index 2, add X, Y
console.log(letters); // ['a', 'b', 'c', 'd', 'e'] - unchanged!
console.log(spliced); // ['a', 'b', 'X', 'Y', 'd', 'e']
// with() - immutable index assignment
const updated = letters.with(2, 'Z');
console.log(letters); // ['a', 'b', 'c', 'd', 'e'] - unchanged!
console.log(updated); // ['a', 'b', 'Z', 'd', 'e']
// Practical example: React-friendly state updates
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn JS', done: false },
{ id: 2, text: 'Build app', done: false }
]);
const toggleTodo = (id) => {
const index = todos.findIndex(t => t.id === id);
const todo = todos[index];
// Immutably update single item
setTodos(todos.with(index, { ...todo, done: !todo.done }));
};
const sortByStatus = () => {
// Done items at bottom
setTodos(todos.toSorted((a, b) => a.done - b.done));
};
const removeTodo = (id) => {
const index = todos.findIndex(t => t.id === id);
setTodos(todos.toSpliced(index, 1));
};
}
Promise.withResolvers()
This utility creates a Promise along with its resolve and reject functions, eliminating the need for the "deferred" pattern.
// The old pattern - verbose and awkward
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// The new way - clean and simple
const { promise, resolve, reject } = Promise.withResolvers();
// Practical example: Event-based async operations
function waitForEvent(element, eventName, timeout = 5000) {
const { promise, resolve, reject } = Promise.withResolvers();
const handler = (event) => {
clearTimeout(timeoutId);
element.removeEventListener(eventName, handler);
resolve(event);
};
const timeoutId = setTimeout(() => {
element.removeEventListener(eventName, handler);
reject(new Error(`Timeout waiting for ${eventName}`));
}, timeout);
element.addEventListener(eventName, handler);
return promise;
}
// Usage
const button = document.querySelector('#submit');
const clickEvent = await waitForEvent(button, 'click');
// Practical example: Queue with async processing
class AsyncQueue {
#queue = [];
#pending = null;
async enqueue(item) {
this.#queue.push(item);
await this.#processNext();
}
async waitForEmpty() {
if (this.#queue.length === 0 && !this.#pending) {
return;
}
const { promise, resolve } = Promise.withResolvers();
this.#emptyPromise = { promise, resolve };
return promise;
}
async #processNext() {
if (this.#pending || this.#queue.length === 0) return;
const item = this.#queue.shift();
this.#pending = this.#process(item);
await this.#pending;
this.#pending = null;
if (this.#queue.length === 0) {
this.#emptyPromise?.resolve();
} else {
await this.#processNext();
}
}
}
Import Assertions and JSON Modules
Modern ECMAScript supports importing non-JavaScript modules directly with type assertions (now called "import attributes").
// Import JSON directly (older syntax with 'assert')
import config from './config.json' assert { type: 'json' };
// Newer syntax using 'with' (ES2024)
import config from './config.json' with { type: 'json' };
// Dynamic import with assertions
const translations = await import(`./i18n/${locale}.json`, {
with: { type: 'json' }
});
// Practical example: Loading configuration
// config.json
// {
// "apiUrl": "https://api.example.com",
// "timeout": 5000,
// "features": { "darkMode": true }
// }
import config from './config.json' with { type: 'json' };
const api = {
baseUrl: config.apiUrl,
timeout: config.timeout,
async fetch(endpoint) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
signal: AbortSignal.timeout(this.timeout)
});
return response.json();
}
};
// Loading locale-specific data dynamically
async function loadTranslations(locale) {
try {
const module = await import(`./locales/${locale}.json`, {
with: { type: 'json' }
});
return module.default;
} catch {
// Fallback to default locale
const module = await import('./locales/en.json', {
with: { type: 'json' }
});
return module.default;
}
}
Common Mistakes to Avoid
When using modern ECMAScript features, avoid these common pitfalls.
1. Confusing Nullish and OR Operators
// ❌ Bad: Using || when you want to preserve falsy values
const port = userPort || 3000; // 0 becomes 3000!
const enabled = userEnabled || true; // false becomes true!
// ✅ Good: Use ?? for nullish-only defaults
const port = userPort ?? 3000; // 0 stays 0
const enabled = userEnabled ?? true; // false stays false
2. Chaining Assignment Operators Incorrectly
// ❌ Bad: Expecting chained behavior
let a = null;
let b = null;
a ??= b ??= 5; // b becomes 5, but a is still null (evaluated left-to-right)
// ✅ Good: Assign explicitly
let a = null;
let b = null;
b ??= 5; // b = 5
a ??= b; // a = 5
3. Assuming structuredClone Copies Everything
// ❌ Bad: Trying to clone functions or DOM nodes
const withFn = { fn: () => 'hello' };
structuredClone(withFn); // Throws DataCloneError
// ✅ Good: Know the limitations
const cleaned = {
data: obj.data,
config: obj.config
// Omit non-cloneable properties
};
const copy = structuredClone(cleaned);
4. Using .at() with Invalid Indexes
// ❌ Potential issue: Not checking for undefined
const items = ['a', 'b'];
const third = items.at(2); // undefined, not error
console.log(third.toUpperCase()); // TypeError!
// ✅ Good: Handle undefined cases
const third = items.at(2) ?? 'default';
// or
const third = items.at(2);
if (third !== undefined) {
console.log(third.toUpperCase());
}
Conclusion
Modern ECMAScript features quietly improve how JavaScript is written and maintained. By using tools like logical assignment operators, top-level await, private class fields, immutable array methods, structuredClone, and safer object utilities, you can eliminate boilerplate, prevent common bugs, and write code that clearly expresses your intentions. These features are available in all modern browsers and Node.js versions, making them safe to adopt in most projects today.
The key is to adopt these features incrementally, starting with the ones that solve problems you encounter frequently. Optional chaining and nullish coalescing are usually the best starting points, followed by the immutable array methods and structuredClone for state management scenarios.
If you want to strengthen your JavaScript workflows, read CI/CD for Node.js Projects Using GitHub Actions. For performance-focused backend techniques, see Using Node.js Streams for Efficient File Processing. To explore functional programming patterns in JavaScript, check out Functional Programming Techniques in JavaScript. You can also explore the ECMAScript proposal repository and the MDN JavaScript documentation to stay up to date on upcoming features. By adopting these features thoughtfully, your JavaScript codebase becomes cleaner, safer, and easier to evolve.
5 Comments