All posts

ES2026 Is Final: Seven Additions and the Code They Let You Delete

Ecma International approved ECMAScript 2026 on June 30. The seven additions are small, unglamorous, and unusually well aimed at boilerplate — here's what each one replaces in a real codebase.

ES2026 Is Final: Seven Additions and the Code They Let You Delete

On 30 June 2026, Ecma International approved the ECMAScript 2026 language specification — the 17th edition of the standard that defines JavaScript. There is no headline feature this year. No Temporal, no decorators, no pattern matching. What landed instead is seven small additions that each remove a specific piece of boilerplate or a specific footgun, and taken together they clear out a surprising amount of glue code.

That's a better outcome than it sounds. The most useful language changes are usually the ones that let you delete a utility file. Here's each addition, what it replaces, and how to think about adoption.

Math.sumPrecise

Summing an array is the single most common reduce in JavaScript, and it quietly loses precision. Floating-point addition isn't associative, so the order of operations changes the answer:

const values = [1e20, 0.1, -1e20];

values.reduce((a, b) => a + b, 0);
// 0

Math.sumPrecise(values);
// 0.1

Math.sumPrecise takes an iterable of numbers and sums them with the full precision of the intermediate result, then rounds once at the end. Where a naive reduce accumulates rounding error at every step, this gives you the correctly rounded sum regardless of magnitude ordering.

If you're totalling line items on an invoice, aggregating telemetry, or accumulating anything over thousands of iterations, this is the method you should reach for. It won't rescue you from representing money as floats — that's still a design mistake, and integer cents or a decimal library remain the right answer — but it removes an entire class of "the total is off by a cent" bug reports from everything else.

Error.isError

instanceof Error has always been unreliable across realms. An error thrown inside an iframe, a worker, a VM context, or a browser extension has a different Error constructor, so instanceof returns false on a perfectly real error. It also returns true for anything with Error.prototype in its chain, including plain objects deliberately shaped to look like errors.

try {
  riskyOperation();
} catch (err) {
  if (Error.isError(err)) {
    logger.error(err.stack);
  } else {
    logger.error(`Non-error thrown: ${String(err)}`);
  }
}

Error.isError is a brand check — it asks whether the value was actually constructed as an error object, not whether its prototype chain looks right. It joins Array.isArray as the correct answer for a question instanceof was never really able to answer. If you maintain an error-normalization helper (and most codebases have one, usually named something like toError), this is the check it should be built on.

Array.fromAsync

Array.from has existed since 2015 for synchronous iterables. Its async counterpart is only arriving now, which is why nearly every codebase that touches streams has a hand-rolled collect helper:

// The old dance
const rows = [];
for await (const row of queryStream) {
  rows.push(row);
}
// ES2026
const rows = await Array.fromAsync(queryStream);

// With a mapping function, same shape as Array.from
const ids = await Array.fromAsync(queryStream, (row) => row.id);

Array.fromAsync accepts async iterables, sync iterables of promises, and array-likes, and it awaits sequentially rather than in parallel — which matters. It is not Promise.all. If you need concurrency, Promise.all is still the tool; Array.fromAsync is for draining an ordered stream into an array, which is what you almost always want when reading from a database cursor, a paginated API wrapper, or a ReadableStream.

Uint8Array to and from Base64 and hex

Converting binary data to base64 in the browser has historically meant btoa plus a String.fromCharCode loop, or a dependency. That's now built in:

const bytes = new Uint8Array([69, 83, 50, 48, 50, 54]);

bytes.toBase64(); // "RVMyMDI2"
bytes.toHex();    // "455332303236"

Uint8Array.fromBase64("RVMyMDI2"); // Uint8Array(6) [69, 83, 50, 48, 50, 54]
Uint8Array.fromHex("455332303236"); // Uint8Array(6) [69, 83, 50, 48, 50, 54]

The methods also accept an options object for base64url alphabet handling, which is what you need when working with JWTs, WebAuthn credential IDs, or anything else that travels in a URL. For teams doing crypto work with SubtleCrypto — hashing, signing, key export — this pairs directly with the ArrayBuffer results those APIs return and eliminates the encoding shim that sits between them.

Iterator.concat

The iterator helpers (map, filter, take, drop) arrived in earlier editions, but there was no way to sequence multiple iterators without writing a generator:

// Before
function* combine(...iterators) {
  for (const source of iterators) yield* source;
}
// ES2026
const combined = Iterator.concat(pageOne, [separator], pageTwo);

Iterator.concat takes any number of iterables and yields their values in order, lazily. Since it accepts plain arrays alongside iterators, it doubles as a clean way to splice fixed values into a stream. The laziness is the point: unlike spreading everything into an array first, nothing is consumed until you pull from the result, so you can concatenate infinite or expensive sources safely.

JSON.parse source text access

JSON round-tripping in JavaScript is lossy in both directions. Parsing a large integer silently corrupts it, and stringifying a BigInt throws:

JSON.parse("999999999999999999");
// 1000000000000000000  — precision already gone

JSON.stringify(9999999999999999n);
// TypeError: Do not know how to serialize a BigInt

ES2026 fixes both ends. The JSON.parse reviver now receives a third argument carrying the raw source text of the value, so you can reconstruct it losslessly before it's ever coerced to a number. And JSON.rawJSON lets a replacer emit a primitive verbatim:

JSON.parse("999999999999999999", (key, value, { source }) => BigInt(source));
// 999999999999999999n

JSON.stringify(9999999999999999n, (key, value) =>
  typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value,
);
// 9999999999999999  — emitted as a JSON number, not a quoted string

If you integrate with APIs that return 64-bit IDs — Twitter-style snowflakes, most financial ledgers, plenty of Java backends — you have almost certainly hit this and worked around it by regex-quoting IDs before parsing. That workaround can now go.

Map.getOrInsert

The last addition is pure convenience, and it's the one you'll type most often. Every grouping and memoization routine contains the same three lines:

// Before
if (!index.has(key)) index.set(key, []);
index.get(key).push(item);
// ES2026
index.getOrInsert(key, []).push(item);

getOrInsert returns the existing value if the key is present and inserts-then-returns the default if it isn't. There's a companion getOrInsertComputed that takes a callback instead of a value, so the default is only constructed when it's actually needed — the right choice when the default is expensive, or when you'd otherwise allocate a throwaway array on every lookup. Both land on Map.prototype and WeakMap.prototype.

What you can actually use today

Ratification is a formality that follows implementation, not the other way around — a proposal reaches Stage 4 only after it ships in multiple engines. So most of this is already available somewhere. The older proposals in the batch (Array.fromAsync, the Uint8Array encoding methods, Error.isError) have the broadest support; the newest two, Iterator.concat and Map.getOrInsert, only started reaching stable browsers during 2026. Check MDN's compatibility table for each specific method rather than assuming the edition ships as a unit — it never does.

A practical adoption order:

  1. Server-side first. If you control the Node.js version, these are the cheapest wins in the list. Bump your engines field and start using them in build scripts and API handlers where there's no browser matrix to negotiate.
  2. Polyfill what's worth polyfilling. core-js covers most of this batch, and with @babel/preset-env and useBuiltIns: "usage" you only ship the polyfills you actually reference. Math.sumPrecise and Error.isError are cheap; think harder before pulling in a base64 polyfill you could avoid.
  3. Feature-detect in shared libraries. If you publish packages, a typeof Math.sumPrecise === "function" guard with a fallback keeps consumers on older runtimes working without forcing a polyfill decision on them.
  4. Update your TypeScript lib target. These methods need the right lib setting to typecheck; there's no runtime cost to being explicit about it.

Takeaways

ES2026 is a maintenance release in the best sense. Nothing here changes how you architect an application, but each addition deletes code you'd otherwise write, review, and keep working — the collect helper, the base64 shim, the has/set/get triple, the toError normalizer, the regex that quotes big integers before parsing. Audit your internal utils directory against this list; there's a good chance two or three files in it are now dead weight. Start on the server where the runtime is yours to choose, feature-detect on the client, and let the polyfill layer shrink on its own as support fills in.

Sources: Ecma International: approval of ECMAScript 2026 · InfoWorld: ECMAScript 2026 specification approved · Paweł Grzybek: What's new in ECMAScript 2026 · ECMAScript 2026 Language Specification

Back to all posts
Next step

Need help shipping software?

Tell us what you're trying to build. A discovery call, a one-page summary within 48 hours, a proposal within a week.

Response · 48h·NDA on request·US contracts only