ES2021~ES2024 Key Features
We'll look at the latest features added from ES2021 through ES2024. These include practical additions such as WeakRef, logical assignment operators, non-mutating array methods, and Promise.withResolvers.
ES2021 (ES12)
String.replaceAll()
const text = 'foo bar foo baz foo';
// Old way: requires global regex
text.replace(/foo/g, 'qux'); // 'qux bar qux baz qux'
// ES2021: replaceAll
text.replaceAll('foo', 'qux'); // 'qux bar qux baz qux'
// No need to escape special characters
const html = '<div class="error">Error</div>';
html.replaceAll('<div', '<section').replaceAll('div>', 'section>');
// Difference from replace
'aaa'.replace('a', 'b'); // 'baa' (first only)
'aaa'.replaceAll('a', 'b'); // 'bbb' (all)
// Can also use regex (g flag required)
'Hello World'.replaceAll(/[aeiou]/gi, '*'); // 'H*ll* W*rld'
Promise.any()
// Returns the result of the first fulfilled Promise
// If all are rejected, throws AggregateError
// Use the fastest server response
try {
const data = await Promise.any([
fetch('https://server1.example.com/api').then(r => r.json()),
fetch('https://server2.example.com/api').then(r => r.json()),
fetch('https://server3.example.com/api').then(r => r.json()),
]);
console.log('Fastest response:', data);
} catch (err) {
// err instanceof AggregateError
console.error('All servers failed:', err.errors);
}
// Promise.any vs Promise.race
// race: first settled (includes failures)
// any: first fulfilled (successes only)
Promise.race([
Promise.reject('failure'),
Promise.resolve('success'),
]).catch(err => console.log(err)); // 'failure' (failure settled first)
Promise.any([
Promise.reject('failure'),
Promise.resolve('success'),
]).then(val => console.log(val)); // 'success' (the successful one)
WeakRef
// Weak reference to an object (does not prevent GC)
class Cache {
#cache = new Map();
set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
// Collected by GC
this.#cache.delete(key);
return undefined;
}
return value;
}
}
// Real-world: image cache
const imageCache = new Map();
function getCachedImage(src) {
const ref = imageCache.get(src);
const cached = ref?.deref();
if (cached) return cached;
const img = new Image();
img.src = src;
imageCache.set(src, new WeakRef(img));
return img;
}
// WeakRef.deref() returns the object before GC, undefined after GC
FinalizationRegistry
// Execute a callback when an object is GC'd
const registry = new FinalizationRegistry((heldValue) => {
console.log(`${heldValue} has been collected by GC`);
});
let obj = { name: 'Resource' };
registry.register(obj, 'Resource object');
obj = null; // remove reference
// The callback is called when GC runs later
// Real-world: resource cleanup
class ManagedResource {
static #registry = new FinalizationRegistry((cleanup) => {
cleanup(); // execute cleanup function
});
constructor(resource) {
this.resource = resource;
ManagedResource.#registry.register(
this,
() => resource.dispose() // automatic cleanup on GC
);
}
}
Logical Assignment Operators
// &&= (Logical AND Assignment)
// x &&= y → x && (x = y)
let config = { debug: true, verbose: false };
config.debug &&= false; // if debug is truthy, set to false
config.verbose &&= true; // if verbose is falsy, no change
// ||= (Logical OR Assignment)
// x ||= y → x || (x = y)
let settings = { theme: '', language: null };
settings.theme ||= 'dark'; // if theme is falsy, set to 'dark'
settings.language ||= 'en'; // if language is null, set to 'en'
// ??= (Nullish Assignment)
// x ??= y → x ?? (x = y)
let options = { timeout: 0, name: '' };
options.timeout ??= 5000; // 0 is not null/undefined, no change
options.name ??= 'default'; // '' is not null/undefined, no change
options.cache ??= true; // undefined, so set to true
// Real-world: setting defaults on options object
function createServer(options = {}) {
options.port ??= 3000;
options.host ??= 'localhost';
options.timeout ??= 30000;
options.debug &&= process.env.NODE_ENV === 'development';
return new Server(options);
}
Numeric Separators
// Improve readability of large numbers
const million = 1_000_000;
const billion = 1_000_000_000;
const hex = 0xFF_FF_FF;
const binary = 0b1010_0001_1000_0101;
const bytes = 0xFF_EC_D5_12;
const bigint = 9_007_199_254_740_991n;
// Decimal points also supported
const pi = 3.141_592_653_589_793;
const e = 2.718_281_828;
console.log(million); // 1000000 (numeric value is the same)
ES2022 (ES13)
Array.at()
const arr = [1, 2, 3, 4, 5];
// Old way: access last element
arr[arr.length - 1]; // 5 (cumbersome)
// at(): index access (negative indices supported)
arr.at(0); // 1
arr.at(-1); // 5 (last)
arr.at(-2); // 4 (second to last)
// Also works on strings and TypedArrays
'hello'.at(-1); // 'o'
new Int32Array([1, 2, 3]).at(-1); // 3
// Real-world
function getLast(arr) {
return arr.at(-1);
}
const history = ['page1', 'page2', 'page3'];
const currentPage = history.at(-1); // 'page3'
const previousPage = history.at(-2); // 'page2'
Object.hasOwn()
// Safe replacement for Object.prototype.hasOwnProperty
const obj = { name: 'Alice' };
// Old way (may not be safe)
obj.hasOwnProperty('name'); // true
// Objects created with Object.create(null) have no hasOwnProperty
const bare = Object.create(null);
bare.name = 'Alice';
// bare.hasOwnProperty('name'); // TypeError!
// Object.hasOwn is always safe
Object.hasOwn(bare, 'name'); // true
Object.hasOwn(obj, 'name'); // true
Object.hasOwn(obj, 'toString'); // false (prototype property)
// Real-world
function processConfig(config) {
if (Object.hasOwn(config, 'timeout')) {
setupTimeout(config.timeout);
}
}
Error.cause
// Error chaining - tracking the root cause
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
return await response.json();
} catch (originalError) {
throw new Error(`Failed to load user ${userId} data`, {
cause: originalError // link the original error
});
}
}
// Traversing the error chain
try {
await fetchUserData(1);
} catch (err) {
console.error(err.message); // Failed to load user 1 data
console.error(err.cause.message); // original error message
// Print full error chain
let current = err;
while (current) {
console.error(current.message);
current = current.cause;
}
}
// Using in custom error classes
class DatabaseError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'DatabaseError';
}
}
async function queryDB(sql) {
try {
return await db.query(sql);
} catch (err) {
throw new DatabaseError(`Query failed: ${sql}`, { cause: err });
}
}
Top-level await (in modules)
// config.mjs - use await at the top level of a module
const response = await fetch('/api/config');
export const config = await response.json();
// db.mjs
import { createClient } from 'redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
export { client };
// Importers automatically wait for completion
// app.mjs
import { config } from './config.mjs';
import { client } from './db.mjs';
// Runs after config and client are ready
Class Private Fields/Methods
class BankAccount {
// private fields: # prefix
#balance = 0;
#owner;
#transactions = [];
constructor(owner, initialBalance = 0) {
this.#owner = owner;
this.#balance = initialBalance;
}
// private method
#validateAmount(amount) {
if (amount <= 0) throw new Error('Amount must be greater than 0');
if (typeof amount !== 'number') throw new Error('Amount must be a number');
}
#recordTransaction(type, amount) {
this.#transactions.push({
type, amount,
balance: this.#balance,
timestamp: new Date()
});
}
// public methods
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
this.#recordTransaction('deposit', amount);
return this;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) throw new Error('Insufficient balance');
this.#balance -= amount;
this.#recordTransaction('withdrawal', amount);
return this;
}
// getter
get balance() { return this.#balance; }
get owner() { return this.#owner; }
// static private
static #instanceCount = 0;
static getInstanceCount() { return BankAccount.#instanceCount; }
}
const account = new BankAccount('Alice', 10000);
account.deposit(5000).withdraw(3000);
console.log(account.balance); // 12000
// account.#balance; // SyntaxError! cannot access private
Class static Blocks
class Config {
static #instance = null;
static debug;
static apiUrl;
// static initialization block
static {
const env = process.env.NODE_ENV ?? 'development';
Config.debug = env !== 'production';
Config.apiUrl = env === 'production'
? 'https://api.example.com'
: 'http://localhost:3000';
}
}
console.log(Config.debug); // true (development environment)
console.log(Config.apiUrl); // 'http://localhost:3000'
ES2023 (ES14)
Array.findLast() / findLastIndex()
const arr = [1, 2, 3, 4, 5, 4, 3];
// findLast: first element matching condition from the end
arr.findLast(x => x > 3); // 4 (first from the end)
arr.findLast(x => x > 10); // undefined
// findLastIndex: index of first matching element from the end
arr.findLastIndex(x => x > 3); // 5 (value at index 5 is 4)
// Real-world: find most recent event
const events = [
{ type: 'click', time: 100 },
{ type: 'scroll', time: 200 },
{ type: 'click', time: 300 },
{ type: 'keypress', time: 400 }
];
const lastClick = events.findLast(e => e.type === 'click');
// { type: 'click', time: 300 }
Immutable Array Methods
const arr = [3, 1, 4, 1, 5, 9, 2];
const original = [...arr];
// toSorted: non-mutating version of sort()
const sorted = arr.toSorted((a, b) => a - b);
// sorted: [1, 1, 2, 3, 4, 5, 9]
// arr: [3, 1, 4, 1, 5, 9, 2] (unchanged)
// toReversed: non-mutating version of reverse()
const reversed = arr.toReversed();
// reversed: [2, 9, 5, 1, 4, 1, 3]
// arr: unchanged
// toSpliced: non-mutating version of splice()
const spliced = arr.toSpliced(2, 1, 99, 100);
// remove 1 element at index 2 and insert 99, 100
// spliced: [3, 1, 99, 100, 1, 5, 9, 2]
// arr: unchanged
// with: replace value at specific index (non-mutating)
const updated = arr.with(0, 999); // replace index 0 with 999
// updated: [999, 1, 4, 1, 5, 9, 2]
// arr: unchanged
// Real-world: immutable state updates in frameworks like React/Vue
const [items, setItems] = useState([3, 1, 4]);
// Old way: copy then mutate
setItems(prev => {
const next = [...prev];
next.sort();
return next;
});
// ES2023 way: more concise
setItems(prev => prev.toSorted());
setItems(prev => prev.with(0, 99));
Hashbang Grammar
#!/usr/bin/env node
// hashbang (shebang) allowed on the very first line of a file
// used in CLI scripts for Node.js, Deno, etc.
console.log('Hello from CLI script!');
ES2024 (ES15)
Promise.withResolvers()
// Old way: cumbersome to access resolve/reject outside the Promise constructor
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ES2024: concise with Promise.withResolvers()
const { promise, resolve, reject } = Promise.withResolvers();
// Real-world: convert event to Promise
function waitForEvent(element, eventName) {
const { promise, resolve, reject } = Promise.withResolvers();
function handler(event) {
element.removeEventListener(eventName, handler);
resolve(event);
}
element.addEventListener(eventName, handler);
// Timeout support
const timeout = setTimeout(() => {
element.removeEventListener(eventName, handler);
reject(new Error(`${eventName} event timed out`));
}, 5000);
return promise.finally(() => clearTimeout(timeout));
}
// Usage
const clickEvent = await waitForEvent(document.getElementById('btn'), 'click');
console.log('Clicked:', clickEvent.target);
// Async queue implementation
function createQueue() {
const pending = [];
let { promise, resolve } = Promise.withResolvers();
return {
push(item) {
pending.push(item);
const prev = resolve;
({ promise, resolve } = Promise.withResolvers());
prev();
},
async *[Symbol.asyncIterator]() {
while (true) {
await promise;
while (pending.length) yield pending.shift();
}
}
};
}
ArrayBuffer resize/transfer
// Resizable ArrayBuffer
const resizable = new ArrayBuffer(1024, { maxByteLength: 4096 });
console.log(resizable.byteLength); // 1024
console.log(resizable.resizable); // true
console.log(resizable.maxByteLength); // 4096
// Resize
resizable.resize(2048);
console.log(resizable.byteLength); // 2048
// transfer: transfer ownership of ArrayBuffer (without copying)
const source = new ArrayBuffer(1024);
const view = new Uint8Array(source);
view[0] = 42;
// transfer: transfers ownership to new ArrayBuffer, source is invalidated
const transferred = source.transfer(512); // can also change size
console.log(source.byteLength); // 0 (invalidated)
console.log(transferred.byteLength); // 512
// Real-world: receive buffer management in WebSocket
const buffer = new ArrayBuffer(4096, { maxByteLength: 16384 });
Object.groupBy() / Map.groupBy()
const products = [
{ name: 'Apple', category: 'fruits', price: 1000 },
{ name: 'Banana', category: 'fruits', price: 500 },
{ name: 'Carrot', category: 'veggies', price: 800 },
{ name: 'Potato', category: 'veggies', price: 600 },
{ name: 'Milk', category: 'dairy', price: 1500 },
];
// Object.groupBy: group into a plain object with string keys
const byCategory = Object.groupBy(products, item => item.category);
// {
// fruits: [{ name: 'Apple', ... }, { name: 'Banana', ... }],
// veggies: [{ name: 'Carrot', ... }, { name: 'Potato', ... }],
// dairy: [{ name: 'Milk', ... }]
// }
// Group by price range
const byPriceRange = Object.groupBy(products, item => {
if (item.price < 700) return 'cheap';
if (item.price < 1200) return 'medium';
return 'expensive';
});
// Map.groupBy: keys can be any type (including objects)
const byPriceObj = Map.groupBy(products, item => item.price > 900 ? 'high' : 'low');
byPriceObj.get('high'); // array of high-price products
byPriceObj.get('low'); // array of low-price products
// Old way (reduce)
const grouped = products.reduce((acc, item) => {
const key = item.category;
(acc[key] ??= []).push(item);
return acc;
}, {});
Well-formed Unicode Strings
// String.prototype.isWellFormed(): check if string is valid Unicode
'hello'.isWellFormed(); // true
'\uD800'.isWellFormed(); // false (lone surrogate)
'\uD800\uDC00'.isWellFormed(); // true (valid surrogate pair)
// String.prototype.toWellFormed(): replace invalid parts with U+FFFD
'\uD800hello'.toWellFormed(); // '\uFFFDhello'
// Real-world: safe processing before URL encoding
function safeEncodeURIComponent(str) {
return encodeURIComponent(str.toWellFormed());
}
Version Summary
| Feature | Version | Description |
|---|---|---|
String.replaceAll | ES2021 | Replace all occurrences |
Promise.any | ES2021 | First fulfilled Promise |
WeakRef | ES2021 | Weak reference |
| Logical assignment `&&=, | =, ??=` | |
Array.at | ES2022 | Negative index access |
Object.hasOwn | ES2022 | Safe hasOwnProperty |
Error.cause | ES2022 | Error chaining |
| Top-level await | ES2022 | Top-level await in modules |
Class private # | ES2022 | True private |
Array.findLast/findLastIndex | ES2023 | Search from end |
toSorted/toReversed/toSpliced/with | ES2023 | Non-mutating array methods |
Promise.withResolvers | ES2024 | External Promise control |
Object.groupBy/Map.groupBy | ES2024 | Grouping |
| ArrayBuffer resize/transfer | ES2024 | Buffer management |
Expert Tips
1. Using non-mutating methods for functional programming
// Pipeline style
const result = data
.toSorted((a, b) => b.score - a.score)
.toSpliced(3) // top 3 only
.map(formatUser); // original remains unchanged
2. Multi-dimensional aggregation with Object.groupBy
// Nested grouping
const byYearAndMonth = Object.groupBy(
transactions,
tx => `${tx.date.getFullYear()}-${String(tx.date.getMonth() + 1).padStart(2, '0')}`
);
3. Implementing a cache with WeakRef and FinalizationRegistry
class SmartCache {
#cache = new Map();
#registry = new FinalizationRegistry(key => {
this.#cache.delete(key);
});
set(key, value) {
const ref = new WeakRef(value);
this.#cache.set(key, ref);
this.#registry.register(value, key);
}
get(key) {
return this.#cache.get(key)?.deref();
}
}