Modern JavaScript features

A practical map of JavaScript from ES2015 onward: bindings, concise data operations, iteration, async flow, and compatibility boundaries.

level intermediate time 10 min at Standard depth
version Node 24
what

ES6 is the former name of ES2015. “ES6+” informally covers ES2015 and later editions, which add syntax and built-ins without replacing JavaScript’s core model.

trap

Concise syntax can hide old semantics: const objects still mutate, spread is shallow, optional chaining protects only the chain you write, and promises do not cancel themselves.

fix

Choose each feature for its semantics, test boundary values such as null, 0, and shared nested objects, and verify both parser support and required runtime APIs.

What it is and why it exists

ES6 is the informal name for the sixth ECMAScript edition, standardized in 2015 and therefore officially called ES2015. An ECMAScript edition defines the JavaScript language; browsers and runtimes such as Node implement that specification. The phrase “ES6+” has no precise upper boundary. It usually means ES2015 together with later, yearly additions such as async functions, object spread, optional chaining, and nullish coalescing.

The useful idea is not a checklist of new punctuation. Modern features give existing JavaScript semantics clearer forms: block-scoped bindings replace many accidental var interactions, destructuring names parts of a value, iterables give collections a shared traversal contract, and promises let asynchronous work compose as values. These features reduce glue code when their underlying rules match the job.

You meet this language layer in current application code, package source, build configuration, and generated code. Even a project that ships transformed bundles is usually authored with modern syntax. The runtime target still matters because a parser, a transform, a built-in object, and a host API are different compatibility boundaries.

This topic is a map of those boundaries and of the features that cross them. Dedicated topics cover destructuring, collections, classes, modules, and asynchronous control flow in more detail. Use this overview to recognize how those pieces fit together and which assumptions require a test.

How it works

Modern JavaScript features fall into two broad groups. Syntax changes affect how source text is parsed: const, arrow functions, destructuring patterns, optional chains, and import declarations are examples. Runtime additions create objects or methods your program calls, such as Map, Set, Promise, and newer collection methods. Some proposals add both syntax and runtime behavior.

let and const create lexical bindings scoped to their nearest block. Both have a temporal dead zone from entry into that block until the declaration is initialized. const prevents another assignment to the binding; it does not freeze an object reached through that binding. Use const when the binding should keep its identity and let when reassignment is part of the algorithm.

Arrow functions shorten function expressions and capture this from the surrounding lexical environment. They do not have their own this, arguments, or constructor behavior. That makes them a good fit for small callbacks, but a poor mechanical replacement for an object method whose receiver must come from the call site.

Destructuring is a pattern, not a general deep-copy operation. An array pattern consumes an iterable in sequence; an object pattern reads named properties. Defaults run only when the extracted value is undefined. Rest syntax gathers remaining inputs, while spread syntax expands inputs into a call, array literal, or object literal.

Array spread follows the iteration protocol. Object spread instead copies own enumerable string and Symbol properties into a new ordinary object. In both common container cases the result is a shallow copy : nested object references remain shared. Property order also matters because a later object spread or property definition wins over an earlier one.

Optional chaining uses short-circuit evaluation when the value to its left is null or undefined. Nullish coalescing, left ?? fallback, chooses the fallback for exactly those two values. This differs from left || fallback, which also replaces 0, false, an empty string, and NaN.

Map, Set, and the iteration protocols make collection behavior explicit. A Map accepts values of any type as keys and preserves insertion order during iteration. A Set stores one entry for each distinct value according to SameValueZero equality. Generators implement iterator state behind a function containing yield, so a consumer can pull values with for...of or spread syntax.

Promises represent eventual completion. An async function always returns a promise, and await suspends that function until the awaited value settles; it does not block the JavaScript thread. Combinators such as Promise.all express a group policy. ECMAScript modules add explicit import and export relationships, with module loading and package resolution supplied by the host runtime.

The practical compatibility path has four checks:

  1. The parser must recognize the source syntax.
  2. Any transform must preserve the semantics your code relies on.
  3. The runtime must provide required built-ins and methods.
  4. The host must provide platform APIs such as the DOM or Node file-system modules.

A successful build proves only the checks performed by that build. It does not prove that a target runtime has every built-in, that a browser supplies a Node API, or that transformed code preserves observable details such as iterator closing and property access order.

Examples

Lexical bindings and concise functions

This example combines block-scoped bindings, an arrow function, parameter destructuring, a default parameter, a template literal, and for...of. Each feature removes a small piece of bookkeeping, but the data flow stays visible: taxRate is one stable binding and lowStockCount is reassigned.

bindings-and-functions.js
const products = [
  { sku: 'A-10', price: 12, stock: 2 },
  { sku: 'B-20', price: 8, stock: 0 },
];

const taxRate = 0.2;

const labelProduct = ({ sku, price, stock = 0 }, currency = 'EUR') => {
  const gross = price * (1 + taxRate);
  return `${sku}: ${gross.toFixed(2)} ${currency}, stock=${stock}`;
};

let lowStockCount = 0;
for (const product of products) {
  console.log(labelProduct(product));
  if (product.stock < 1) lowStockCount += 1;
}

console.log(`low stock: ${lowStockCount}`);
A-10: 14.40 EUR, stock=2
B-20: 9.60 EUR, stock=0
low stock: 1

The parameter pattern documents which product fields the formatter reads. Its stock = 0 default would apply to a missing property or an explicit undefined, but not to null. The loop uses const for product because each iteration creates a binding that is not reassigned in the loop body.

Normalize a record without erasing valid falsy data

Boundary code often needs layered defaults. Destructuring handles absent fields, optional chaining handles a missing intermediate object, ?? preserves a valid zero, and object spread applies user preferences after the defaults. The function returns a new result instead of modifying its input.

normalize-customer.js
function normalizeCustomer(raw = {}) {
  const {
    id = 'guest',
    contact,
    preferences = {},
    retries = 3,
  } = raw;

  return {
    id,
    email: contact?.email ?? 'missing',
    preferences: { theme: 'light', ...preferences },
    retries,
  };
}

const customer = normalizeCustomer({
  id: 'U-7',
  contact: null,
  preferences: { density: 'compact' },
  retries: 0,
});

console.log(JSON.stringify(customer));
{"id":"U-7","email":"missing","preferences":{"theme":"light","density":"compact"},"retries":0}

The explicit null contact stops at contact?.email, so the expression produces undefined and ?? supplies missing. A retry count of 0 survives because the code does not use ||. The nested preferences object is also new, although any objects stored inside it would still be shared.

Index events with collection protocols

Map expresses an index whose keys are user IDs, while each Set removes duplicate actions for one user. The generator exposes summaries lazily. The final spread consumes that generator into an array, showing that built-in collections and user-defined generators share the same iteration protocol.

index-events.js
const events = [
  ['U-1', 'view'],
  ['U-2', 'buy'],
  ['U-1', 'buy'],
  ['U-1', 'view'],
];

const actionsByUser = new Map();

for (const [userId, action] of events) {
  const actions = actionsByUser.get(userId) ?? new Set();
  actions.add(action);
  actionsByUser.set(userId, actions);
}

function* summarize(index) {
  for (const [userId, actions] of index) {
    yield `${userId}:${[...actions].join(',')}`;
  }
}

console.log([...summarize(actionsByUser)].join(' | '));
U-1:view,buy | U-2:buy

The output follows first insertion order for map keys and set values. Re-adding view for U-1 does not create a second set entry and does not move the original one. If you need sorting rather than insertion order, request it explicitly instead of relying on a different collection type.

Compose independent asynchronous work

The two waits are independent, so Promise.all starts observing both before either result is used. The async function resumes with an array of results in input order, even though the items promise settles first. The .mjs extension tells Node 24 to treat this self-contained file as a module and therefore permits top-level await.

load-order.mjs
const wait = (value, milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds, value));

async function loadOrderSummary() {
  const [customer, items] = await Promise.all([
    wait({ name: 'Mira' }, 20),
    wait(
      [
        { quantity: 2, unitPrice: 6.25 },
        { quantity: 1, unitPrice: 5 },
      ],
      5,
    ),
  ]);

  const total = items.reduce(
    (sum, { quantity, unitPrice }) => sum + quantity * unitPrice,
    0,
  );

  return `${customer.name}: ${items.length} items, EUR ${total.toFixed(2)}`;
}

console.log(await loadOrderSummary());
Mira: 2 items, EUR 17.50

Promise.all keeps result positions aligned with its input positions; settlement order does not reorder them. If either input rejects, the combined promise rejects. The other underlying operation continues unless its own API supports cancellation and your code requests it.

Pitfalls

Fix: use const to state binding intent, then choose a separate data-ownership policy. Construct new objects when callers require snapshots, expose mutation through a narrow owner when state is intentional, and test nested references. Object.freeze() is shallow and is not a substitute for understanding who owns the nested values.

Fix: use destructuring defaults for undefined, and use ?? when both null and undefined mean absent. Validate separately if null is malformed rather than absent. Add tests for every falsy value accepted by the domain instead of testing only a missing property.

Fix: construct public output from an explicit allowlist. Copy nested domain values at the level where ownership must change, and do not assume spread preserves prototypes, accessors, or property descriptors. Test by mutating a nested result and by adding an unexpected credential to the input.

Fix: mark each genuinely optional hop, for example account?.profile?.name, and decide where a missing value becomes an error or fallback. Do not append ?. everywhere: required fields should still fail validation rather than quietly propagating undefined.

Fix: pass an AbortSignal to operations that support cooperative cancellation, or wait for required cleanup before releasing shared resources. Choose Promise.allSettled only when the caller really needs every outcome; it changes reporting policy, not cancellation behavior.

Deep Editions, transforms, and runtime support

Editions, transforms, and runtime support

ECMAScript 2015 was unusually large, which is why “ES6” remains common shorthand. The specification has used yearly editions since then. A current runtime does not switch on one monolithic “ES6 mode”; it implements a set of syntax and runtime semantics from multiple editions. Node 24 in this topic’s frontmatter records the environment used for the runnable examples, not a claim about every browser.

Edition names answer where a language feature was standardized. They do not answer whether a project can use it. That decision also depends on target engines, the file’s parsing goal, build transforms, and available globals. Package metadata and file extensions can change whether Node parses one file as an ECMAScript module or CommonJS even when the JavaScript tokens are otherwise valid.

Parsing happens before feature detection

Unsupported syntax is normally a parse-time failure. The engine cannot run a conditional such as if (supportsOptionalChaining) when it cannot parse an optional chain elsewhere in the same file. A build transform can lower syntax before the target sees it, or you can deliver a different file to that target. Runtime feature detection applies to values the engine can already parse.

Built-ins have a separate failure mode. Source containing new Map() can parse in an engine even when Map is absent, then fail with a reference error when that line runs. A syntax transformer does not necessarily install globals or methods. Polyfills, when a project uses them, must match the target, loading order, and semantics required by the application.

Concise syntax preserves observable work

Shorter source does not mean fewer semantic steps. Object destructuring performs property reads, which may invoke getters or proxy traps. Array destructuring requests iterator values and may close an iterator when the pattern finishes early. Object spread enumerates eligible keys and reads their values in a defined order. A later failure does not roll back effects that already happened.

This matters at trust boundaries. Destructuring validates no types by itself, optional chaining does not distinguish a deliberately missing field from malformed data, and object spread does not define an output schema. Treat those forms as access and construction syntax. Validate external values and state the public fields separately.

Modules change the loading contract

An ECMAScript module has its own top-level scope, runs in strict mode, and exposes bindings only through explicit exports. Static import declarations are resolved as part of module loading rather than as ordinary function calls. Top-level await is module syntax, so the host must first classify the file as a module.

The ECMAScript specification defines module records and language behavior, while Node and browsers define how specifiers locate resources. A path that resolves in a bundler may not resolve when passed directly to Node. Verify the actual deployment entry point, its package metadata, and its import specifiers rather than inferring module behavior from an editor’s syntax highlighting.

Compatibility claims need executable evidence

A useful compatibility test runs the artifact that the target receives. For untransformed Node code, that may be node entry.mjs. For a bundled browser application, it means building with the production target configuration and opening the produced artifact in the oldest supported engine. Unit tests on source files cover application behavior, but not necessarily the parser and loader used in deployment.

Record the target and build path next to the claim. “Modern JavaScript” is too loose for a release decision; “the unbundled .mjs entry runs on Node 24” is testable. When support changes, repeat the same command or browser test instead of reconstructing what “ES6+” meant to the previous author.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?