Modern JS: ES2020 to ES2026
Mental model: The language now ships every June, so "modern JavaScript" is not a version you learn once, it is a habit of checking what has landed and what your engines actually support.
Level: advanced · about 24 minutes
Since ES2015 the specification has been released annually, every June, containing whatever proposals reached Stage 4 in time. That changed the question from "which version do we target?" to "which features can our engines run?". This lesson is the catalogue, year by year, with the support caveats stated plainly.
| Stage | Meaning | Safe to use? |
|---|---|---|
| 0 and 1 | an idea, a champion, a rough sketch | no |
| 2 | draft spec text exists | no, syntax still changes |
| 2.7 | spec text reviewed, awaiting implementations and tests | experiment only |
| 3 | candidate: implementations and feedback wanted | with a compiler, if you accept churn |
| 4 | finished: two implementations plus tests, in the next annual spec | yes, once your engines ship it |
ES2020
const response = { data: { user: { name: 'Ada', tags: [] } } };
console.log(response.data?.user?.name); // -> Ada
console.log(response.data?.account?.balance); // -> undefined, no TypeError
console.log(response.data?.user?.tags?.[0]); // -> undefined
console.log(response.onDone?.()); // -> undefined, safe optional call
const count = 0;
console.log(count ?? 10, count || 10); // -> 0 10 ?? only guards null and undefined
// BigInt for integers beyond 2^53 - 1
console.log(9007199254740993n + 1n); // -> 9007199254740994n
console.log(typeof 1n, 1n == 1, 1n === 1); // -> bigint true false
// matchAll gives you every match with its groups
const text = 'tea:250 mug:700';
console.log([...text.matchAll(/(\w+):(\d+)/g)].map((m) => [m[1], Number(m[2])]));
// -> [ [ 'tea', 250 ], [ 'mug', 700 ] ]
console.log(typeof globalThis); // -> object, one name in every environmentThe two operators that deleted a whole genre of defensive code.
?.optional chaining, including?.()and?.[]??nullish coalescingBigIntand thenliteral suffixString.prototype.matchAllPromise.allSettled- dynamic
import()in every context globalThisimport.meta, andexport * as ns fromfor...inenumeration order finally specified
ES2021
console.log('a-b-c'.replaceAll('-', '+')); // -> a+b+c
// Before this you needed a global regex, and had to escape the pattern.
const settings = { theme: null, retries: 0, name: '' };
settings.theme ??= 'dark'; // assigns, was null
settings.retries ??= 3; // does nothing, 0 is not nullish
settings.name ||= 'anonymous'; // assigns, '' is falsy
console.log(settings); // -> { theme: 'dark', retries: 0, name: 'anonymous' }
console.log(1_000_000 === 1000000); // -> true, separators are readability only
Promise.any([
Promise.reject(new Error('slow mirror failed')),
Promise.resolve('fast mirror'),
]).then((winner) => console.log('any ->', winner)); // -> any -> fast mirror
Promise.any([Promise.reject(new Error('a')), Promise.reject(new Error('b'))]).catch((err) =>
console.log(err.constructor.name, err.errors.length) // -> AggregateError 2
);replaceAll, logical assignment, numeric separators and Promise.any.
const obj = { a: 0, b: null };
let calls = 0;
const load = () => { calls += 1; return 'loaded'; };
obj.a ??= load();
obj.b ??= load();
obj.a ||= load();
console.log(obj.a, obj.b, calls);Logical assignment short-circuits, so the right-hand side only evaluates when the assignment will actually happen. obj.a ??= load() sees 0, which is not nullish, so load is never called. obj.b ??= load() sees null and assigns, calling load once. Then obj.a ||= load() sees the falsy 0 and assigns, calling load a second time. So loaded loaded 2. That short-circuit is not just an optimisation: it also means the assignment does not fire setters or proxy traps when it is skipped.
ES2022
class Counter {
count = 0; // public field
#step; // private field
static registry = new Set();
static { // static initialisation block
Counter.registry.add('Counter');
}
constructor(step = 1) {
this.#step = step;
}
tick() {
this.count += this.#step;
return this;
}
static isCounter(value) {
return #step in value; // private brand check, no try/catch needed
}
}
const c = new Counter(5).tick().tick();
console.log(c.count, Counter.isCounter(c), Counter.isCounter({})); // -> 10 true false
console.log([...Counter.registry]); // -> [ 'Counter' ]
console.log([10, 20, 30].at(-1), 'hello'.at(-1)); // -> 30 o
console.log(Object.hasOwn({ a: 1 }, 'a'), Object.hasOwn({}, 'toString')); // -> true false
const wrapped = new Error('could not save', { cause: new RangeError('disk full') });
console.log(wrapped.message, '<-', wrapped.cause.name); // -> could not save <- RangeErrorClass fields, private members, static blocks, at, hasOwn and error cause.
- class fields,
#privatefields and methods,staticblocks,#x in objbrand checks - top-level
awaitin modules .at()on arrays, strings and typed arraysObject.hasOwn, which replacesObject.prototype.hasOwnProperty.callErrorcauseoption- regexp match indices with the
dflag
ES2023
const readings = [3, 8, 2, 9, 4];
console.log(readings.findLast((n) => n > 3), readings.findLastIndex((n) => n > 3)); // -> 4 4
console.log(readings.toSorted((a, b) => a - b)); // -> [ 2, 3, 4, 8, 9 ]
console.log(readings.toReversed()); // -> [ 4, 9, 2, 8, 3 ]
console.log(readings.with(0, 99)); // -> [ 99, 8, 2, 9, 4 ]
console.log(readings.toSpliced(1, 2, 'x')); // -> [ 3, 'x', 9, 4 ]
console.log(readings); // -> [ 3, 8, 2, 9, 4 ] unchanged
// Symbols became legal WeakMap keys
const key = Symbol('per-request');
const wm = new WeakMap([[key, 'metadata']]);
console.log(wm.get(key)); // -> metadatafindLast plus the four non-mutating array twins.
ES2024
const orders = [
{ id: 1, status: 'paid', total: 250 },
{ id: 2, status: 'pending', total: 700 },
{ id: 3, status: 'paid', total: 120 },
];
const byStatus = Object.groupBy(orders, (order) => order.status);
console.log(Object.keys(byStatus), byStatus.paid.length); // -> [ 'paid', 'pending' ] 2
console.log(Object.getPrototypeOf(byStatus)); // -> null, safe as a lookup table
const byBucket = Map.groupBy(orders, (o) => (o.total > 200 ? 'big' : 'small'));
console.log([...byBucket.keys()]); // -> [ 'big', 'small' ]
// withResolvers: the deferred pattern, without the constructor dance
const { promise, resolve } = Promise.withResolvers();
setTimeout(() => resolve('settled from outside'), 0);
promise.then((v) => console.log(v));
console.log('\uD800'.isWellFormed(), '\uD800'.toWellFormed().length); // -> false 1groupBy and withResolvers, the two you will use weekly.
The old deferred dance
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// works, but the executor runs
// synchronously and the two
// variables are declared with
// no value for one statement
ES2024
const { promise, resolve, reject } =
Promise.withResolvers();
// same thing, one line, and
// impossible to get wrongUseful whenever the thing that settles a promise is not inside the promise: a message handler, an event listener, a queue drain, or a lock.
Object.groupByandMap.groupByPromise.withResolvers- resizable
ArrayBufferandArrayBuffer.prototype.transfer String.prototype.isWellFormedandtoWellFormedAtomics.waitAsync- the regexp
vflag, with set notation and string properties
ES2025
function* naturals() {
let n = 1;
while (true) yield n++;
}
const hasHelpers = typeof naturals().map === 'function';
console.log('iterator helpers:', hasHelpers);
if (hasHelpers) {
console.log(naturals().map((n) => n * n).filter((n) => n % 2 === 1).take(4).toArray());
// -> [ 1, 9, 25, 49 ]
console.log(new Map([['a', 1], ['b', 2]]).values().reduce((a, b) => a + b, 0)); // -> 3
}
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5]);
if (typeof a.union === 'function') {
console.log([...a.union(b)]); // -> [ 1, 2, 3, 4, 5 ]
console.log([...a.intersection(b)]); // -> [ 3, 4 ]
console.log([...a.difference(b)]); // -> [ 1, 2 ]
console.log([...a.symmetricDifference(b)]); // -> [ 1, 2, 5 ]
console.log(new Set([3]).isSubsetOf(a), a.isDisjointFrom(new Set([99]))); // -> true true
}Iterator helpers and set methods: the biggest ergonomic jump in years.
// Promise.try: run a function now, get a promise either way
if (typeof Promise.try === 'function') {
Promise.try(() => { throw new Error('sync throw'); }).catch((e) => console.log('caught:', e.message));
Promise.try((n) => n * 2, 21).then((v) => console.log('try ->', v)); // -> try -> 42
}
// RegExp.escape: build a pattern from user input safely
if (typeof RegExp.escape === 'function') {
const term = 'price (GBP) *';
const re = new RegExp(RegExp.escape(term), 'i');
console.log(re.test('The price (GBP) * today')); // -> true
}
// Array.fromAsync: for await ... of, collected
async function* pages() {
yield ['a', 'b'];
yield ['c'];
}
if (typeof Array.fromAsync === 'function') {
Array.fromAsync(pages()).then((all) => console.log('fromAsync ->', all.flat()));
// -> fromAsync -> [ 'a', 'b', 'c' ]
}
// Float16Array: half precision, mostly for GPU and ML data
if (typeof Float16Array === 'function') {
const half = new Float16Array([1.5, 0.1]);
console.log(half[0], half[1].toFixed(4)); // -> 1.5 0.0999 (precision is limited on purpose)
}Promise.try, RegExp.escape, Array.fromAsync and Float16Array.
- iterator helpers:
map,filter,take,drop,flatMap,reduce,toArray,some,every,find - new
Setmethods:union,intersection,difference,symmetricDifference,isSubsetOf,isSupersetOf,isDisjointFrom Promise.tryRegExp.escape- regexp inline modifiers, for example
(?i:part) - duplicate named capture groups in alternate branches
Float16Array,Math.f16roundandDataViewfloat16 accessors- import attributes and JSON modules:
import data from './x.json' with { type: 'json' } Array.fromAsync
ES2026, and what is still landing
This is the honest part of the lesson. The 2026 edition is being finalised as this is written, and the features below are at Stage 3 or freshly Stage 4 with uneven engine support. Treat the year label as provisional and check your own target matrix before shipping any of them without a build step.
| Feature | What it gives you | Support in 2026 |
|---|---|---|
using / await using | deterministic cleanup (lesson 15.6) | V8 engines yes (Chrome 134+, Node 24+), others later |
Symbol.dispose / asyncDispose | the protocol behind using | as above, and trivially polyfilled |
DisposableStack | runtime-assembled cleanup | as above |
| decorators | declarative class element wrapping (lesson 15.5) | no native engine support, needs TypeScript or Babel |
Temporal | a correct date and time API at last | Firefox shipping, Safari in progress, V8 behind a flag |
Error.isError | a cross-realm reliable error check | shipping in current V8 and Safari |
Uint8Array base64 and hex | fromBase64, toBase64, fromHex, toHex | Firefox and Safari first, V8 catching up |
Math.sumPrecise | exact summation of a list of floats | partial, check before use |
immutable ArrayBuffer | transferToImmutable | early, V8 first |
Atomics.pause | a spin-wait hint for shared memory | early |
// Error.isError works across realms, unlike instanceof
const isError = typeof Error.isError === 'function'
? Error.isError
: (v) => Object.prototype.toString.call(v) === '[object Error]';
console.log(isError(new TypeError('x')), isError({ message: 'fake' })); // -> true false
// Temporal is not everywhere yet, so guard it
if (typeof globalThis.Temporal === 'undefined') {
console.log('Temporal is not available in this engine, using Date');
} else {
console.log('Temporal is available');
}
// Feature detect syntax, not just APIs
const supports = (source) => {
try {
new Function(source);
return true;
} catch {
return false;
}
};
console.log('using declarations:', supports('{ using x = { [Symbol.dispose]() {} }; }'));
console.log('decorators: ', supports('class A { @dec m() {} }'));The only responsible way to use a landing feature: detect, then fall back.
| Old pattern | Modern replacement | Since |
|---|---|---|
a && a.b && a.b.c | a?.b?.c | ES2020 |
x !== null && x !== undefined ? x : d | x ?? d | ES2020 |
str.replace(/-/g, '+') | str.replaceAll('-', '+') | ES2021 |
if (!o.k) o.k = v | o.k ??= v | ES2021 |
arr[arr.length - 1] | arr.at(-1) | ES2022 |
Object.prototype.hasOwnProperty.call(o, k) | Object.hasOwn(o, k) | ES2022 |
[...arr].sort(fn) | arr.toSorted(fn) | ES2023 |
arr.reduce((acc, x) => { ... }, {}) to group | Object.groupBy(arr, fn) | ES2024 |
manual deferred with captured resolve | Promise.withResolvers() | ES2024 |
| hand-written lazy generators | iterator helpers | ES2025 |
[...new Set([...a, ...b])] | a.union(b) | ES2025 |
new RegExp(userInput) | new RegExp(RegExp.escape(userInput)) | ES2025 |
try/finally for cleanup | using | ES2026 |
Try it yourself
What does this engine actually support?
const checks = {
'optional chaining': () => ({ a: null }).a?.b === undefined,
'nullish coalescing': () => (0 ?? 1) === 0,
'logical assignment': () => { const o = { a: null }; o.a ??= 2; return o.a === 2; },
'Object.hasOwn': () => typeof Object.hasOwn === 'function',
'Array.prototype.at': () => [1, 2].at(-1) === 2,
'Error cause': () => new Error('x', { cause: 'y' }).cause === 'y',
'toSorted': () => typeof [].toSorted === 'function',
'Object.groupBy': () => typeof Object.groupBy === 'function',
'Promise.withResolvers': () => typeof Promise.withResolvers === 'function',
'iterator helpers': () => typeof [].values().take === 'function',
'Set.union': () => typeof new Set().union === 'function',
'Promise.try': () => typeof Promise.try === 'function',
'RegExp.escape': () => typeof RegExp.escape === 'function',
'Float16Array': () => typeof Float16Array === 'function',
'Symbol.dispose': () => typeof Symbol.dispose === 'symbol',
'DisposableStack': () => typeof DisposableStack === 'function',
'Error.isError': () => typeof Error.isError === 'function',
};
for (const [name, check] of Object.entries(checks)) {
let ok = false;
try { ok = Boolean(check()); } catch { ok = false; }
console.log((ok ? 'yes ' : 'no ') + name);
}
Add checks for Temporal, Uint8Array.prototype.toBase64 and decorator syntax. Then group the results with Object.groupBy by whether they passed.
Refactor to modern
const orders = [
{ id: 1, customer: { name: 'Ada' }, status: 'paid', total: 250 },
{ id: 2, customer: null, status: 'pending', total: 700 },
{ id: 3, customer: { name: 'Grace' }, status: 'paid', total: 120 },
];
// The 2015 version
function summarise(list) {
const groups = {};
for (let i = 0; i < list.length; i++) {
const order = list[i];
const key = order.status;
if (!groups[key]) groups[key] = [];
groups[key].push(order);
}
const names = [];
for (let i = 0; i < list.length; i++) {
const name = list[i].customer && list[i].customer.name ? list[i].customer.name : 'guest';
names.push(name);
}
const sorted = list.slice().sort(function (a, b) { return b.total - a.total; });
return { groups: Object.keys(groups), names: names, biggest: sorted[0].id, smallest: sorted[sorted.length - 1].id };
}
console.log(summarise(orders));
// -> { groups: [ 'paid', 'pending' ], names: [ 'Ada', 'guest', 'Grace' ], biggest: 2, smallest: 3 }
Rewrite summarise using Object.groupBy, at, toSorted and optional chaining, and check the output still matches. Then measure whether the modern version is easier to read aloud.
Exercises
Implement Object.groupBy
Write groupBy(items, keyFn) matching the ES2024 Object.groupBy. It returns an object with a null prototype, whose keys are the property keys produced by keyFn(item, index) and whose values are arrays of the matching items in input order. Keys are coerced to strings (except symbols, which stay symbols). Any iterable is accepted, and an empty input gives an empty object.
Implement Promise.withResolvers
Write withResolvers() returning { promise, resolve, reject }, matching the ES2024 method. The promise must settle when either function is called from anywhere, later calls must be ignored, resolve must adopt a thenable, and the functions must work when detached from the object.
Check yourself
- What does
const port = 0; const chosen = port ?? 3000;produce, and why? 0, because??only falls back onnullandundefined—??tests for nullish, not for falsy, so0,'',falseandNaNall pass through. That is precisely why it was added:||swallowed legitimate zero and empty-string values in default-argument code. TheSyntaxErroroption is a real rule, but only when you mix??with&&or||in the same expression without parentheses.- Why does
Object.groupByreturn an object with anullprototype? - so that a group key such as
__proto__,constructorortoStringcannot collide with inherited members or pollute anything — Group keys come from your data, which often comes from users. With no prototype there is nothing to shadow and nothing to pollute, and'toString' in resultis honestlyfalse. It is a small, deliberate design decision that tells you the committee learned from a decade of prototype pollution bugs. The result serialises fine withJSON.stringify. - What is the practical difference between
arr.values().map(fn).take(3)andarr.map(fn).slice(0, 3)? - the iterator version is lazy and calls
fnat most three times, the array version maps the whole array first — Iterator helpers pull one value at a time, sotake(3)stops the pipeline after three values andfnruns three times.arr.mapbuilds a complete new array first andslicethen discards most of it. For small arrays the difference is noise; for expensive callbacks, huge arrays, or infinite generators it is the difference between working and not. - A feature you want is at Stage 3 and shipping in one engine. What is the responsible move?
- use it only through a compiler or behind a feature detection fallback, and accept that the semantics may still change — Stage 3 means the spec text is written and implementers are being asked for feedback, so details can and do change (decorators changed shape entirely between designs). A compiler or a guarded fallback keeps you shippable. There is no Stage 5, and patching a built-in prototype with a not-yet-final API is how you get a conflict when the real one lands.
Common mistakes
- Using
||where??is meant, which discards0and the empty string. - Mixing
??with&&or||without parentheses, which is aSyntaxError. - Expecting the right-hand side of
??=to always evaluate. It short-circuits. - Reaching for
.at(-1)on an array-like that is not an array. - Using
Object.groupByand then calling a method on the result, which a null-prototype object does not have. - Assuming iterator helpers exist on arrays. They live on iterators, so you need
arr.values()first. - Shipping decorators or Temporal without checking engine support or adding a compiler.
- Confusing Stage 3 with finished.
- Monkey-patching built-in prototypes to polyfill a proposal that is not final.
Takeaways
- JavaScript ships every June. The question is engine support, not language version.
- Stage 4 means final. Stage 3 means usable with a compiler and a tolerance for change.
- ES2020 gave
?.,??,BigInt,matchAll,allSettledandglobalThis. - ES2021 gave
replaceAll,Promise.any, logical assignment and numeric separators. - ES2022 gave class fields,
#private, static blocks, top-level await,at,Object.hasOwnand errorcause. - ES2023 gave
findLastand the non-mutating array twinstoSorted,toReversed,toSpliced,with. - ES2024 gave
Object.groupBy,Map.groupBy,Promise.withResolversand resizable buffers. - ES2025 gave iterator helpers, set methods,
Promise.try,RegExp.escape, JSON modules andArray.fromAsync. - ES2026 brings
usingand friends, with decorators and Temporal still landing unevenly. - About fifteen features carry the daily weight. Learn those deeply and look the rest up.