ES2021–2022 Features
ES2021 and ES2022 continued JavaScript’s annual release cadence, delivering small but high-impact ergonomic wins. ES2021 cleaned up everyday string and promise work, while ES2022 modernized classes and made modules and arrays friendlier. Most of these features are supported in all evergreen browsers and Node 16+, so you can reach for them in production today.
ES2021 features
String.prototype.replaceAll
Before ES2021, replacing every occurrence of a substring meant passing a global regex. replaceAll lets you pass a plain string and swap all matches without escaping regex metacharacters.
const path = "src/utils/src/index.js";
console.log(path.replace("src", "lib")); // only the first match
console.log(path.replaceAll("src", "lib")); // every match
Output:
lib/utils/src/index.js
lib/utils/lib/index.js
If you pass a regex to
replaceAll, it MUST have the global (g) flag or it throws aTypeError.
Promise.any and AggregateError
Promise.any resolves as soon as the first promise fulfills, ignoring rejections. It only rejects if every promise rejects, in which case you get an AggregateError whose errors array holds each rejection reason. Contrast this with Promise.race, which settles on the first promise to settle (fulfill or reject).
const first = await Promise.any([
fetch("https://eu.api.example.com/data"),
fetch("https://us.api.example.com/data"),
fetch("https://asia.api.example.com/data"),
]);
const data = await first.json();
console.log(data);
| Combinator | Resolves when | Rejects when |
|---|---|---|
Promise.all | all fulfill | any rejects |
Promise.allSettled | all settle | never |
Promise.race | first settles | first settles (if rejection) |
Promise.any | first fulfills | all reject (AggregateError) |
Logical assignment operators
These combine a logical operator with assignment, and they short-circuit — the assignment only runs when needed.
let config = { retries: 0, timeout: null };
config.retries ||= 3; // assigns if falsy (0 is falsy → becomes 3)
config.timeout ??= 5000; // assigns only if null/undefined
config.cache &&= true; // assigns only if current value is truthy
console.log(config);
Output:
{ retries: 3, timeout: 5000 }
Note ||= treats 0 and "" as “missing”, while ??= only fills null/undefined. Prefer ??= for defaults when zero or empty string are valid values.
Numeric separators
Underscores can be placed inside numeric literals to make large numbers readable. They’re purely cosmetic and ignored at parse time.
const billion = 1_000_000_000;
const bytes = 0xFF_FF_FF;
const card = 4_242_4242_4242_4242n;
console.log(billion === 1000000000); // true
WeakRef
WeakRef holds a reference to an object without preventing garbage collection. Call .deref() to read it — you get undefined if the object has been collected. This is an advanced tool for caches; avoid it in normal code.
const cache = new WeakRef(buildExpensiveObject());
const value = cache.deref();
if (value) useValue(value);
ES2022 features
Top-level await
Inside an ES module you can now use await at the top level, without wrapping it in an async function. Importing such a module waits for its awaits to settle.
// config.js — an ES module
const res = await fetch("https://api.example.com/config");
export const config = await res.json();
Top-level await only works in ES modules (
type: "module"or.mjs), not CommonJS. It can delay the loading of any module that imports it, so use it for genuine startup dependencies.
Class fields and private members
Class fields let you declare instance properties directly in the class body. Prefixing a name with # makes it truly private — inaccessible outside the class, even via bracket notation.
class Counter {
count = 0; // public field
#step = 1; // private field
static label = "ctr"; // static field
increment() {
this.count += this.#step;
return this.count;
}
#reset() { // private method
this.count = 0;
}
}
const c = new Counter();
console.log(c.increment()); // 1
console.log(c.count); // 1
// console.log(c.#step); // SyntaxError
Output:
1
1
Static initialization blocks
A static {} block runs once when the class is defined, letting you run setup logic that needs multiple statements or access to private static fields.
class Settings {
static #defaults;
static {
const raw = '{"theme":"dark"}';
Settings.#defaults = JSON.parse(raw);
}
static get theme() {
return Settings.#defaults.theme;
}
}
console.log(Settings.theme); // "dark"
Array.at and indexed access
.at() accepts negative indices, so arr.at(-1) reads the last element without writing arr[arr.length - 1]. It works on arrays, strings, and typed arrays.
const items = ["a", "b", "c"];
console.log(items.at(-1)); // "c"
console.log("hello".at(-2)); // "l"
Object.hasOwn
Object.hasOwn(obj, key) is a safer replacement for Object.prototype.hasOwnProperty.call(obj, key). It works even on objects created with Object.create(null) that lack the prototype method.
const dict = Object.create(null);
dict.id = 42;
console.log(Object.hasOwn(dict, "id")); // true
console.log(Object.hasOwn(dict, "name")); // false
Error cause
The Error constructor now accepts an options object with a cause, letting you chain errors while preserving the original.
try {
JSON.parse("{ bad json");
} catch (err) {
throw new Error("Failed to load config", { cause: err });
}
The wrapping error keeps a reference to the underlying SyntaxError via its .cause property, which is invaluable for debugging.
Best Practices
- Prefer
replaceAllover global regexes for plain-string replacements — it’s clearer and avoids escaping bugs. - Use
??=for defaults when0,"", orfalseare legitimate values; reserve||=for genuine falsy fallbacks. - Reach for
Promise.anywhen you want the first success and can tolerate partial failures, e.g. racing redundant endpoints. - Use
#privatefields for true encapsulation instead of the_naming convention. - Limit top-level await to startup-critical work, since it blocks every importer of the module.
- Attach
{ cause }when re-throwing so error context is never lost across layers.