# Modern JavaScript features

Source: https://codewiki.com/javascript/es6-features/

> - **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.

<!-- quick -->

```javascript
// file: 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}`);
```

```text
A-10: 14.40 EUR, stock=2
B-20: 9.60 EUR, stock=0
low stock: 1
```

<!-- /quick -->

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.

```javascript
// file: 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));
```

```text
{"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.

```javascript
// file: 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(' | '));
```

```text
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`.

```javascript
// file: 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());
```

```text
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

> **Pitfall:** Treating `const` as immutable data hides mutations behind a stable binding. `const settings = {}`
> prevents `settings = other`, but `settings.theme = 'dark'` remains valid.

**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.

> **Pitfall:** Replacing every default with `||` loses valid falsy values. Generated configuration code often
> turns `0`, `false`, or `''` into a fallback even though only missing data should use that fallback.

**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.

> **Pitfall:** Spread is a shallow property operation, not a universal clone or redaction boundary. Nested values
> remain aliased, object spread can invoke getters, and a denylist such as `{ password, ...publicUser }`
> silently exposes any sensitive field added later.

**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.

> **Pitfall:** Optional chaining protects only the nullable steps marked in the chain. If `account` exists but
> `account.profile` is missing, `account?.profile.name` still attempts to read `name` from `undefined`.
> Grouping as `(account?.profile).name` also ends the protected chain.

**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`.

> **Pitfall:** `Promise.all` rejects when an input rejects, but it does not cancel the remaining operations.
> Generated code often reports failure and releases resources while another request is still using them.

**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

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.

<!-- /deep -->

[Checkpoint: javascript/es6-features](https://codewiki.com/javascript/es6-features/#checkpoint)

## Further reading

- [ECMAScript language specification](https://tc39.es/ecma262/)
- [ECMAScript specification: `let` and `const` declarations](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-let-and-const-declarations)
- [ECMAScript specification: object initializer and spread properties](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-object-initializer)
- [ECMAScript specification: optional chains](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-optional-chains)
- [Node.js 24 documentation: ECMAScript modules](https://nodejs.org/docs/latest-v24.x/api/esm.html)
