The useful part of the ECMAScript 2026 standard for JavaScript developers is not a dramatic change to the language. It is that several common use cases now have direct names.
JavaScript gets a new language standard every year. Some editions introduce syntax that changes how programs are written or how they perform. The ECMAScript 2026 (ES17) version brings a handful of useful features which we will look at. These features add focused APIs for tasks that JavaScript developers already handle with small helpers, repeated checks or easy-to-miss workarounds.
In this article, I will focus on additions that can remove code, make intent clearer or prevent subtle errors.
Runtime support: These APIs did not arrive in every runtime at the same time. Check the Browser compatibility table on each linked MDN page for the browsers and runtimes you support. Use a tested fallback when an API is unavailable.

Image generated with AI
Build a Map as Values Arrive
In my article about array grouping, I showed a simple way to group related things using Object.groupBy() and Map.groupBy(). Both methods begin with a collection that already exists, but what if you want to do the same for streaming data?
For example, this code groups employees from an asynchronous stream:
const employeesByDepartment = new Map();
for await (const employee of employeeStream) {
if (!employeesByDepartment.has(employee.department)) {
employeesByDepartment.set(employee.department, []);
}
employeesByDepartment
.get(employee.department)
.push(employee);
}
There is nothing complicated here, but the has(), set() and get() sequence is boilerplate code around the operation we actually care about—adding a new employee to a department.
ECMAScript 2026 adds Map.prototype.getOrInsert() and Map.prototype.getOrInsertComputed(). These methods return the value corresponding to the specified key. If not present, it inserts a new entry with the key and a given default value, and returns the inserted value.
You should use getOrInsertComputed() whenever the default should be created lazily, particularly when creating it is expensive, has side effects or allocates a mutable object such as an array. With the computed version, the code from before becomes:
const employeesByDepartment = new Map();
for await (const employee of employeeStream) {
employeesByDepartment
.getOrInsertComputed(employee.department, () => [])
.push(employee);
}
The result is simple and compact.
Collect an Asynchronous Iterable into an Array
An asynchronous generator is a useful way to hide pagination. Its caller can consume one sequence without knowing where one response page ends and the next begins. For example:
async function* fetchAllIssues(url) {
while (url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const page = await response.json();
yield* page.items;
url = page.next;
}
}
To collect every result into an array, you would normally write a loop:
const issues = [];
for await (const issue of fetchAllIssues("/api/issues")) {
issues.push(issue);
}
The Array.fromAsync() method can be used to perform that collection directly:
const issues = await Array.fromAsync(
fetchAllIssues("/api/issues"),
);
Isn’t that shorter and simpler?
Despite its name, the method also accepts synchronous iterables and array-like objects. It awaits values from those sources one at a time.
Array.fromAsync() also accepts a mapping function, and the runtime waits for the mapped result before reading the next value. For example, the same code can be adjusted to return only the issue title using the map function:
const issueTitles = await Array.fromAsync(
fetchAllIssues("/api/issues"),
(issue) => issue.title,
);
Given that example, it might be misleading to think that Array.fromAsync() runs independent operations concurrently. For example:
const sequential = await Array.fromAsync(urls, fetchJson);
const concurrent = await Promise.all(urls.map(fetchJson));
You should use Promise.all() when independent operations can run at the same time. And reach for Array.fromAsync() when the source or mapping step may be asynchronous, and you want lazy, ordered consumption. Also remember what the method returns—an array containing every result. Keep the for await...of loop when you want to process a large stream incrementally and avoid collecting a stream that may never end.
Combine Iterables into One Lazy Sequence
Sometimes several iterable sources should behave like one continuous sequence. Suppose an application checks its built-in routes first, then registered routes by plugins and finally a catch-all route. You could spread everything into a new array, but that would eagerly consume each iterable and allocate another collection.
A generator can keep the sequence lazy:
function* allRoutes() {
yield* builtInRoutes;
yield* pluginRoutes;
yield fallbackRoute;
}
Iterator.concat(), on the other hand, expresses the same operation without the custom generator:
const allRoutes = Iterator.concat(
builtInRoutes,
pluginRoutes,
[fallbackRoute],
);
The result yields the built-in routes first, followed by the plugin routes and then the fallback. Values are pulled only as the consumer advances the iterator; Iterator.concat() does not collect them into a new array first. Each argument must be an iterable object, which is why the example wraps fallbackRoute in an array.
The Iterator.concat()method works with synchronous iterables only. It does not combine asynchronous iterables.
Preserve Large Integers in JSON
Chat platforms often hand out snowflake IDs as part of a message or other kinds of data. These are commonly 64-bit integers and can exceed Number.MAX_SAFE_INTEGER, so parsing them as JSON numbers can silently lose precision. Consider this response:
const payload = `{
"messageId": 1183028002140618753,
"channel": "general"
}`;
const event = JSON.parse(payload);
console.log(event.messageId);
The logged value isn’t the value in the JSON text.
ECMAScript 2026 gives the JSON.parse() reviver function a third argument, named context. When the value is an unmodified primitive from the parser, context.source contains the original JSON text. We can use that to parse and convert the text to the proper type, in this case BigInt.
Here’s the sample from earlier, rewritten using the reviver function:
const event = JSON.parse(
payload,
(key, value, context) => {
if (key === "messageId") {
return BigInt(context.source);
}
return value;
},
);
console.log(event.messageId);
By the time the reviver receives value, the Number has already lost precision. However, context.source lets the code ignore that damaged value and build a BigInt from the original digits instead.
Serialization has the opposite problem: JSON.stringify() throws when it reaches a BigInt, unless you handle the value. The new JSON.rawJSON() method lets a replacer function for JSON.stringify provide valid JSON text for a primitive value. The JSON.rawJSON() method creates a “raw JSON” object containing JSON text.
Here’s an example using JSON.stringify() and JSON.rawJSON() together:
const json = JSON.stringify(
event,
(key, value) =>
typeof value === "bigint"
? JSON.rawJSON(value.toString())
: value,
);
console.log(json);
Used together, the two APIs let this program recover the original digits as a BigInt and serialize those digits back into JSON without rounding them.
Using those APIs doesn’t mean you should turn every integer into a BigInt. A count, price and database identifier may all appear as JSON numbers, but they do not necessarily belong in the same underlying JavaScript type. That said, it is important to remember that the parser exposes context.source only for unmodified primitive values, and JSON.rawJSON() accepts only valid JSON text representing a primitive value.
Convert Bytes to and from Base64 or Hex
Binary data has often taken an unnecessary detour through strings. Converting to and from bytes wasn’t a natural interface in the language. I think Bun was the first JS runtime I used that had a built-in API for converting bytes to various data types. Fortunately, the 2026 ECMAScript standard release brings the following functions for converting bytes:
- Uint8Array.prototype.toBase64
- Uint8Array.prototype.toHex
- Uint8Array.prototype.setFromHex and its static form Uint8Array.fromHex
- Uint8Array.prototype.setFromBase64 and its static form Uint8Array.fromBase64
How are they useful, you may ask?
Imagine you want to create a URL-safe token. The code might look like this:
const bytes = crypto.getRandomValues(new Uint8Array(32));
const token = btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
The program starts with bytes, turns them into a temporary string, encodes that string and then adjusts the alphabet and padding. It works, but none of those intermediate steps express the real task: encode these bytes as base64url.
We can use the Uint8Array.prototype.toBase64() method to do the conversion instead:
const bytes = crypto.getRandomValues(new Uint8Array(32));
const token = bytes.toBase64({
alphabet: "base64url",
omitPadding: true,
});
The reverse conversion is also as simple as:
const decoded = Uint8Array.fromBase64(token, {
alphabet: "base64url",
});
The setFromBase64() and setFromHex() methods write into an existing array and return an object with read and written counts. Unlike fromBase64() and fromHex(), they are useful when you need to control memory allocation, decode into a preallocated buffer or track how much input fits. See the docs for Uint8Array.fromBase64() to learn about the available input options and runtime support.
These methods do not replace TextEncoder or TextDecoder. Use those APIs to convert between text and bytes; use the new Uint8Array methods to convert between bytes and base64 or hexadecimal representations.
Recognize Error Objects Across Realms
The instanceof Error looks like the obvious way to check if an object is an Error. It stops being reliable when the value comes from another JavaScript realm, e.g., an iframe or the Node.js vm context. Each realm has its own Error constructor, so a genuine error from another realm can fail an instanceof check.
Try this in the browser console:
const iframe = document.createElement("iframe");
document.body.append(iframe);
const otherError = new iframe.contentWindow.Error("Failure");
console.log(otherError instanceof Error);
This may not surprise many experienced JavaScript programmers who have been deceived by some JavaScript quirkiness.
The solution is to use the new Error.isError(). Error.isError() performs a built-in check by testing for the internal [[ErrorData]] slot instead of relying on the current realm’s prototype chain. This makes it analogous to Array.isArray() as a reliable cross-realm check.
If you append console.log(Error.isError(otherError)) to the previous code snippet you ran in your browser console, you should see the correct result.
The method is also useful in a catch block because JavaScript allows any value to be thrown:
try {
await runPlugin();
} catch (value) {
const error = Error.isError(value)
? value
: new Error(String(value), { cause: value });
reportError(error);
}
You should use Error.isError() when you need to know whether a value is a real Error object. It deliberately does not treat a plain object with name and message properties as one.
Sum Floating-point Values More Accurately
A straightforward reduce() can lose information while adding floating-point values, and the failure mode is sneakier than you’d expect, because the result doesn’t always look obviously wrong. Consider a motion-sensor library that applies a large per-device calibration offset, adds a small reading, then removes the offset again:
const readings = [1e16, 3.5, -1e16];
const total = readings.reduce(
(sum, value) => sum + value,
0,
);
console.log(total);
The correct answer is 3.5—that’s the actual reading once the offset cancels out. Near 1e16, adjacent representable numbers are two units apart. The exact value 1e16 + 3.5 therefore rounds to 1e16 + 4, and subtracting the offset leaves 4 instead of 3.5. The result isn’t a crash but a plausible-looking wrong number, which is what makes this type of bug easy to miss in code review.
That’s where Math.sumPrecise() comes in. It uses a more accurate summation algorithm, so switching from .reduce() to Math.sumPrecise() gets you the correct answer.
Here’s an accurate way to rewrite it:
const total = Math.sumPrecise(readings);
console.log(total);
The Math.sumPrecise() method accepts an iterable of numbers. It does not coerce strings or BigInt values into numbers. An empty iterable, or one containing only -0, returns -0. The name deserves one warning though, because Precise does not mean decimal arithmetic precision. This familiar result does not change:
console.log(Math.sumPrecise([0.1, 0.2]));
Both inputs are already binary floating-point approximations. Math.sumPrecise() reduces the additional error introduced while summing them; it does not change how JavaScript represents numbers. That makes it useful for numerical aggregation, but not a complete solution for money. Use an appropriate decimal type or an integer representation for financial values.
That’s a Wrap
The useful part of the ECMAScript 2026 standard is not a dramatic change to the language. It is that several common use cases now have direct names: get a Map value or create it, preserve the original digits from JSON, encode bytes without pretending they are text, collect an asynchronous sequence, recognize a real Error, sum numbers with less loss and join iterables lazily. None of these APIs will transform an application on its own. They can, however, replace code that is easy to repeat (boilerplate code), easy to get slightly wrong or harder to understand than the operation it performs.
That is why JavaScript has become nicer to use!
Make sure to check runtime support before using these additions in production. The standard defines the language, but every runtime follows its own release schedule. You can get the source code for some of the examples on GitHub.
Further Reading