Skip to main content

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

FeatureVersionDescription
String.replaceAllES2021Replace all occurrences
Promise.anyES2021First fulfilled Promise
WeakRefES2021Weak reference
Logical assignment `&&=,=, ??=`
Array.atES2022Negative index access
Object.hasOwnES2022Safe hasOwnProperty
Error.causeES2022Error chaining
Top-level awaitES2022Top-level await in modules
Class private #ES2022True private
Array.findLast/findLastIndexES2023Search from end
toSorted/toReversed/toSpliced/withES2023Non-mutating array methods
Promise.withResolversES2024External Promise control
Object.groupBy/Map.groupByES2024Grouping
ArrayBuffer resize/transferES2024Buffer 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();
}
}