AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

14 Modern JavaScript and its tooling / Lesson 1 of 5

ES2020–ES2022 — the features that changed everyday code

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

What is the difference between ?? and || in JavaScript?

The nullish coalescing operator ?? returns its right-hand side only when the left is null or undefined; the logical OR || returns it for any falsy value, including 0, "" and false. So config.port ?? 3000 keeps a port of 0, while config.port || 3000 replaces it. Mixing ?? with || or && without parentheses is a syntax error.

JavaScript gains features every year; most are small, a few rewire how ordinary code looks. Between 2020 and 2022 the language got the two operators that removed most defensive ifs (?. and ??), logical assignment, BigInt, the last two promise combinators, real class privacy, Error.cause, top-level await and a handful of methods that end common workarounds (at, replaceAll, Object.hasOwn). Everything here runs on Node 16 and in every evergreen browser, and this module's exercises use all of it. This lesson is the feature list with the precise semantics and the one pitfall of each.

Optional chaining ?.

user?.address?.city          // undefined if user or address is null/undefined — instead of throwing
user?.["home-address"]       // computed access
onDone?.(result)             // call only if defined
arr?.[0]

Short-circuits the whole chain on the first nullish link: a?.b.c.d evaluates to undefined without touching .c when a is nullish. It only guards null/undefined — a 0 or "" continues. Do not sprinkle it everywhere: ?. on a value that should exist hides a bug; use it where absence is a legitimate state.

Nullish coalescing ?? and logical assignment

const port = config.port ?? 3000;        // only null/undefined trigger the default — 0 stays 0
const label = config.label || "untitled";  // any falsy triggers — "" becomes "untitled"
opts.retries ??= 3;                       // assign only if nullish
opts.verbose ||= false;                   // assign if falsy
cache.entry &&= refresh(cache.entry);     // assign only if truthy

?? versus || is the difference between "missing" and "falsy" — choose deliberately (module 7). Mixing ?? with ||/&& without parentheses is a syntax error by design. The logical assignments short-circuit: a ??= f() does not call f when a is already set (unlike a = a ?? f(), which also always assigns — a difference visible with setters).

BigInt

const big = 9007199254740993n;                  // beyond Number.MAX_SAFE_INTEGER; the n suffix makes a BigInt
big + 1n;  big * 2n;  big ** 2n;  7n / 2n;     // 3n — integer division truncates
typeof big;                                      // "bigint"
big + 1;                                          // TypeError: cannot mix BigInt and other types — convert explicitly: Number(big), BigInt(1)
BigInt(1.5);                                      // RangeError
JSON.stringify({ big });                          // TypeError — provide toJSON or convert to string
0n === 0; 0n == 0;                                // false, true

Arbitrary-precision integers for ids, timestamps in nanoseconds, cryptography, exact money arithmetic. Mixed arithmetic is an error on purpose; Math functions do not accept BigInt.

Promise.allSettled (2020) and Promise.any (2021)

Module 8 covered them: every outcome, and first success with AggregateError on total failure. AggregateError itself is 2021.

globalThis, import(), import.meta

globalThis is the global object in every environment (window, self, global were platform-specific). Dynamic import() and import.meta (import.meta.url) are 2020 and the basis of module 9's ESM story. export * as ns from "mod" completes the re-export forms.

Strings and arrays

"a-b-c".replaceAll("-", "+");                      // every occurrence without a regex (2021)
[..."a1b2".matchAll(/\d/g)];                      // every match with groups (2020)
[1, 2, 3].at(-1);  "abc".at(-1);                  // negative indices (2022)
Object.hasOwn(obj, "k");                          // the safe hasOwnProperty (2022) — works on Object.create(null)
[1, [2, [3]]].flat(Infinity);  arr.flatMap(f);    // 2019, still underused
1_000_000; 0xFF_FF; 1_000n;                      // numeric separators (2021) — purely visual

Classes: fields, private members, static blocks (2022)

class Counter {
  static instances = 0;
  static { Counter.instances = 0; }        // static initialisation block — several statements at class load
  #count = 0;                              // private field
  count = 0;                               // public field
  #bump() { this.#count++; }               // private method
  static isCounter(x) { return #count in x; }   // brand check (16.4+)
  get value() { return this.#count; }
}

Module 5 covered the semantics; this is the year they became standard everywhere.

Error.cause (2022) and top-level await (2022)

new Error("save failed", { cause }) — module 7's wrapping idiom. Top-level await in ES modules makes module evaluation asynchronous and importers wait — for configuration and connections at startup; not available in CommonJS.

Regular expressions

The d flag / hasIndices (2022) gives match.indices with [start, end] per group — for highlighting and error positions (module 10). matchAll requires g. Named groups and lookbehind were 2018.

WeakRef and FinalizationRegistry (2021)

Module 6: hold without retaining; rarely appropriate.

Feature detection, and when to polyfill

Everything above is syntax or built-ins. Syntax (?., ??=, #x, numeric separators) cannot be polyfilled — a runtime that lacks it fails to parse the file; only a transpiler (Babel, esbuild, TypeScript with a lower target) can rewrite it. Built-ins (replaceAll, at, Object.hasOwn, Promise.any) can be detected and filled: if (!Array.prototype.at) { Object.defineProperty(Array.prototype, "at", { value(i) { … }, writable: true, configurable: true }); } — non-enumerable, guarded, following the spec (core-js does this for everything). Detect features, never user agents.

Common mistakes

  • ?. on values that must exist (masking bugs); a?.b.c expecting .c to be guarded too (it is — the chain short-circuits — but (a?.b).c is not).
  • || where ?? was meant for 0/""; ?? mixed with || without parentheses.
  • Mixing BigInt and Number; JSON.stringify on BigInt.
  • Polyfilling syntax (impossible) or patching prototypes with enumerable properties.
  • Assuming a feature exists because it is "standard" — check the runtime's version.

Interview angle

  • "?? versus ||?" Nullish (null/undefined) versus falsy; 0 and "" survive ??.
  • "What does a?.b.c do when a is null?" The whole chain short-circuits to undefined; .c is never evaluated.
  • "Why can't BigInt mix with Number?" Precision would be ambiguous; the language forces explicit conversion.
  • "Can you polyfill optional chaining?" No — syntax needs transpiling; only built-ins can be polyfilled.
  • "Name three ES2022 features." Class fields/private members/static blocks, Error.cause, top-level await, at(), Object.hasOwn, the d flag.

Key takeaways

  • ?. guards nullish links and short-circuits the chain; ??/??= mean "missing", ||/||= mean "falsy".
  • BigInt for big integers, never mixed implicitly with Number; n suffix; no JSON.
  • allSettled/any/AggregateError, globalThis, import()/import.meta, matchAll, replaceAll, at, Object.hasOwn, numeric separators.
  • 2022: class fields, #private, static blocks, brand checks, Error.cause, top-level await, regex d.
  • Syntax must be transpiled; built-ins can be detected and polyfilled — detect features, not browsers.

Common questions

How does optional chaining work in JavaScript?

a?.b evaluates to undefined instead of throwing when a is null or undefined, and the whole chain short-circuits: in a?.b.c.d, nothing after the first nullish link is evaluated. It works for calls, fn?.(), and computed access, obj?.[key]; a 0 or "" does not stop the chain.

What does ??= do in JavaScript?

Logical nullish assignment: a ??= b assigns b only when a is null or undefined, and it short-circuits, so b is not even evaluated when a is already set. ||= assigns when the target is falsy and &&= when it is truthy; all three arrived in ES2021.

Why can't you mix BigInt and Number in JavaScript?

Because the precision of the result would be ambiguous, so 1n + 1 throws a TypeError and you convert explicitly with Number(big) or BigInt(1). BigInt literals take an n suffix, integer division truncates, Math functions do not accept BigInt, and JSON.stringify throws on one.

What features were added in ES2022?

Class fields, #private fields and methods, static initialisation blocks and #x in obj brand checks; Error.cause; top-level await in ES modules; at() on arrays and strings; Object.hasOwn; and the regular-expression d flag, which reports the start and end indices of each match group.

Can optional chaining be polyfilled?

No. ?., ??=, private fields and numeric separators are syntax: a runtime that lacks them cannot parse the file, so only a transpiler such as Babel, esbuild or TypeScript with a lower target can rewrite them. Built-ins such as replaceAll, at and Object.hasOwn can be feature-detected and polyfilled.

Mark as readSign in to keep your place.

JavaScript Nullish Coalescing, Optional Chaining and ES2022

The ES2020 to ES2022 JavaScript features: optional chaining, nullish coalescing, logical assignment, BigInt, private fields, top-level await and Object.hasOwn.

  • Course: JavaScript study plan
  • Module: Modern JavaScript and its tooling
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: Node 16

What is the difference between ?? and || in JavaScript?

The nullish coalescing operator ?? returns its right-hand side only when the left is null or undefined; the logical OR || returns it for any falsy value, including 0, "" and false. So config.port ?? 3000 keeps a port of 0, while config.port || 3000 replaces it. Mixing ?? with || or && without parentheses is a syntax error.

Lesson

JavaScript gains features every year; most are small, a few rewire how ordinary code looks. Between 2020 and 2022 the language got the two operators that removed most defensive ifs (?. and ??), logical assignment, BigInt, the last two promise combinators, real class privacy, Error.cause, top-level await and a handful of methods that end common workarounds (at, replaceAll, Object.hasOwn). Everything here runs on Node 16 and in every evergreen browser, and this module's exercises use all of it. This lesson is the feature list with the precise semantics and the one pitfall of each.

Optional chaining ?.

user?.address?.city          // undefined if user or address is null/undefined — instead of throwing
user?.["home-address"]       // computed access
onDone?.(result)             // call only if defined
arr?.[0]

Short-circuits the whole chain on the first nullish link: a?.b.c.d evaluates to undefined without touching .c when a is nullish. It only guards null/undefined — a 0 or "" continues. Do not sprinkle it everywhere: ?. on a value that should exist hides a bug; use it where absence is a legitimate state.

Nullish coalescing ?? and logical assignment

const port = config.port ?? 3000;        // only null/undefined trigger the default — 0 stays 0
const label = config.label || "untitled";  // any falsy triggers — "" becomes "untitled"
opts.retries ??= 3;                       // assign only if nullish
opts.verbose ||= false;                   // assign if falsy
cache.entry &&= refresh(cache.entry);     // assign only if truthy

?? versus || is the difference between "missing" and "falsy" — choose deliberately (module 7). Mixing ?? with ||/&& without parentheses is a syntax error by design. The logical assignments short-circuit: a ??= f() does not call f when a is already set (unlike a = a ?? f(), which also always assigns — a difference visible with setters).

BigInt

const big = 9007199254740993n;                  // beyond Number.MAX_SAFE_INTEGER; the n suffix makes a BigInt
big + 1n;  big * 2n;  big ** 2n;  7n / 2n;     // 3n — integer division truncates
typeof big;                                      // "bigint"
big + 1;                                          // TypeError: cannot mix BigInt and other types — convert explicitly: Number(big), BigInt(1)
BigInt(1.5);                                      // RangeError
JSON.stringify({ big });                          // TypeError — provide toJSON or convert to string
0n === 0; 0n == 0;                                // false, true

Arbitrary-precision integers for ids, timestamps in nanoseconds, cryptography, exact money arithmetic. Mixed arithmetic is an error on purpose; Math functions do not accept BigInt.

Promise.allSettled (2020) and Promise.any (2021)

Module 8 covered them: every outcome, and first success with AggregateError on total failure. AggregateError itself is 2021.

globalThis, import(), import.meta

globalThis is the global object in every environment (window, self, global were platform-specific). Dynamic import() and import.meta (import.meta.url) are 2020 and the basis of module 9's ESM story. export * as ns from "mod" completes the re-export forms.

Strings and arrays

"a-b-c".replaceAll("-", "+");                      // every occurrence without a regex (2021)
[..."a1b2".matchAll(/\d/g)];                      // every match with groups (2020)
[1, 2, 3].at(-1);  "abc".at(-1);                  // negative indices (2022)
Object.hasOwn(obj, "k");                          // the safe hasOwnProperty (2022) — works on Object.create(null)
[1, [2, [3]]].flat(Infinity);  arr.flatMap(f);    // 2019, still underused
1_000_000; 0xFF_FF; 1_000n;                      // numeric separators (2021) — purely visual

Classes: fields, private members, static blocks (2022)

class Counter {
  static instances = 0;
  static { Counter.instances = 0; }        // static initialisation block — several statements at class load
  #count = 0;                              // private field
  count = 0;                               // public field
  #bump() { this.#count++; }               // private method
  static isCounter(x) { return #count in x; }   // brand check (16.4+)
  get value() { return this.#count; }
}

Module 5 covered the semantics; this is the year they became standard everywhere.

Error.cause (2022) and top-level await (2022)

new Error("save failed", { cause }) — module 7's wrapping idiom. Top-level await in ES modules makes module evaluation asynchronous and importers wait — for configuration and connections at startup; not available in CommonJS.

Regular expressions

The d flag / hasIndices (2022) gives match.indices with [start, end] per group — for highlighting and error positions (module 10). matchAll requires g. Named groups and lookbehind were 2018.

WeakRef and FinalizationRegistry (2021)

Module 6: hold without retaining; rarely appropriate.

Feature detection, and when to polyfill

Everything above is syntax or built-ins. Syntax (?., ??=, #x, numeric separators) cannot be polyfilled — a runtime that lacks it fails to parse the file; only a transpiler (Babel, esbuild, TypeScript with a lower target) can rewrite it. Built-ins (replaceAll, at, Object.hasOwn, Promise.any) can be detected and filled: if (!Array.prototype.at) { Object.defineProperty(Array.prototype, "at", { value(i) { … }, writable: true, configurable: true }); } — non-enumerable, guarded, following the spec (core-js does this for everything). Detect features, never user agents.

Common mistakes

  • ?. on values that must exist (masking bugs); a?.b.c expecting .c to be guarded too (it is — the chain short-circuits — but (a?.b).c is not).
  • || where ?? was meant for 0/""; ?? mixed with || without parentheses.
  • Mixing BigInt and Number; JSON.stringify on BigInt.
  • Polyfilling syntax (impossible) or patching prototypes with enumerable properties.
  • Assuming a feature exists because it is "standard" — check the runtime's version.

Interview angle

  • "?? versus ||?" Nullish (null/undefined) versus falsy; 0 and "" survive ??.
  • "What does a?.b.c do when a is null?" The whole chain short-circuits to undefined; .c is never evaluated.
  • "Why can't BigInt mix with Number?" Precision would be ambiguous; the language forces explicit conversion.
  • "Can you polyfill optional chaining?" No — syntax needs transpiling; only built-ins can be polyfilled.
  • "Name three ES2022 features." Class fields/private members/static blocks, Error.cause, top-level await, at(), Object.hasOwn, the d flag.

Key takeaways

  • ?. guards nullish links and short-circuits the chain; ??/??= mean "missing", ||/||= mean "falsy".
  • BigInt for big integers, never mixed implicitly with Number; n suffix; no JSON.
  • allSettled/any/AggregateError, globalThis, import()/import.meta, matchAll, replaceAll, at, Object.hasOwn, numeric separators.
  • 2022: class fields, #private, static blocks, brand checks, Error.cause, top-level await, regex d.
  • Syntax must be transpiled; built-ins can be detected and polyfilled — detect features, not browsers.

Common questions

How does optional chaining work in JavaScript?

a?.b evaluates to undefined instead of throwing when a is null or undefined, and the whole chain short-circuits: in a?.b.c.d, nothing after the first nullish link is evaluated. It works for calls, fn?.(), and computed access, obj?.[key]; a 0 or "" does not stop the chain.

What does ??= do in JavaScript?

Logical nullish assignment: a ??= b assigns b only when a is null or undefined, and it short-circuits, so b is not even evaluated when a is already set. ||= assigns when the target is falsy and &&= when it is truthy; all three arrived in ES2021.

Why can't you mix BigInt and Number in JavaScript?

Because the precision of the result would be ambiguous, so 1n + 1 throws a TypeError and you convert explicitly with Number(big) or BigInt(1). BigInt literals take an n suffix, integer division truncates, Math functions do not accept BigInt, and JSON.stringify throws on one.

What features were added in ES2022?

Class fields, #private fields and methods, static initialisation blocks and #x in obj brand checks; Error.cause; top-level await in ES modules; at() on arrays and strings; Object.hasOwn; and the regular-expression d flag, which reports the start and end indices of each match group.

Can optional chaining be polyfilled?

No. ?., ??=, private fields and numeric separators are syntax: a runtime that lacks them cannot parse the file, so only a transpiler such as Babel, esbuild or TypeScript with a lower target can rewrite them. Built-ins such as replaceAll, at and Object.hasOwn can be feature-detected and polyfilled.

Exercises

The 2020–2021 toolkit in one program

Line 1 is a JSON config, line 2 a number n, line 3 a sentence. Merge the config with the defaults { port: 3000, host: "localhost", retries: 3, debug: false } using ??= and, for contrast, with ||=; print merged=<JSON> and viaOr=<JSON> differs=<bool>. Print city=<config.owner?.address?.city ?? "unknown"> firstTag=<config.tags?.[0] ?? "none"> onReady=<typeof config.onReady?.call>. Compute n! with BigInt and print <n>! = <value> digits=<> typeof=bigint safe=<≤ MAX_SAFE_INTEGER>, then bigint+number=<constructor name of the error from fact + 1> bigint+1n=true. Finally title-case the sentence with matchAll and print titleCase=<> dashes=<sentence.replaceAll(" ", "-")> million=<1_000_000> hex=<0xff_ff>.

Example: {"port":0,"host":"","owner":{"address":{"city":"Paris"}},"tags":[]} / 25 / hello brave new world →

merged={"port":0,"host":"","owner":{"address":{"city":"Paris"}},"tags":[],"retries":3,"debug":false}
viaOr={"port":3000,"host":"localhost","owner":{"address":{"city":"Paris"}},"tags":[],"retries":3,"debug":false} differs=true
city=Paris firstTag=none onReady=undefined
25! = 15511210043330985984000000 digits=26 typeof=bigint safe=false
bigint+number=TypeError bigint+1n=true
titleCase=Hello Brave New World dashes=hello-brave-new-world million=1000000 hex=65535

??= kept the deliberate 0 and ""; ||= overwrote them.

ES2022 class features, cause and indices

Write Counter with static instances, a static registry filled in a static block, private #count and #history, a private method #bump(), inc(times), get value(), get last() (via this.#history.at(-1) ?? null) and static isCounter(x) using the brand check #count in x. Commands: new <name> → new <name>: instances=<n>; inc <name> <times> → <name>: value=<n>; check <name> → isCounter(<name>)=<> isCounter(plain)=<for a plain {name}> hasOwn(name)=<Object.hasOwn(counter, "name")> hasOwn(value)=<…>; last <name> → <name>: last=<n|null>; fail <name> <message> throws a RangeError("inner: <message>"), wraps it in Error("outer: <name> failed", { cause }) and prints <message> <- <cause.name>: <cause.message>. Finally print indices: word=<start-end> host=<start-end> from /(?<word>\w+)@(?<host>\w+)/d on "mail ada@example now".

Example: new a, inc a 3, check a, last a, fail a disk full →

new a: instances=1
a: value=3
isCounter(a)=true isCounter(plain)=false hasOwn(name)=true hasOwn(value)=false
a: last=3
outer: a failed <- RangeError: inner: disk
indices: word=5-8 host=9-16

(value is an accessor on the prototype, so it is not an own property.)

In this module: Modern JavaScript and its tooling

← Checkpoint — TypeScript preview · ES2023 onward, and how features get into the language →