Skip to content
JavaScript js modern 4 min read

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 a TypeError.

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);
CombinatorResolves whenRejects when
Promise.allall fulfillany rejects
Promise.allSettledall settlenever
Promise.racefirst settlesfirst settles (if rejection)
Promise.anyfirst fulfillsall 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 replaceAll over global regexes for plain-string replacements — it’s clearer and avoids escaping bugs.
  • Use ??= for defaults when 0, "", or false are legitimate values; reserve ||= for genuine falsy fallbacks.
  • Reach for Promise.any when you want the first success and can tolerate partial failures, e.g. racing redundant endpoints.
  • Use #private fields 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.
Last updated June 1, 2026
Was this helpful?