JavaScript number precision for large IDs, and ten new features for agent code
JSON.parse can turn a 19-digit ID into a different number without an error. How to keep IDs exact, then ten new JavaScript features for agent code, starting with that fix: what ships now, and what is still a proposal.

A long numeric ID can come out of JSON.parse as a different number, and nothing throws.
const text = '{"id":1234567890123456789}'
const { id } = JSON.parse(text)
BigInt(id) // 1234567890123456768n
JSON.stringify({ id }) // '{"id":1234567890123456800}'
JavaScript numbers are 64-bit floats. Every whole number up to 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER) is exact. Above that, only some whole numbers fit, and among 19-digit numbers they’re 128 to 2,048 apart. JSON.parse rounds the ID to the nearest one that fits (MDN, ECMA-262).
So the ID changes twice. Parsing rounds it, which BigInt(id) shows: the original digits are gone, and no conversion brings them back. Then JSON.stringify writes the fewest digits that identify the float and fills the rest with zeros (ECMA-262). Even an ID that fits exactly can change that second way. Whatever reads the text next, the model or another API, gets a different ID.
Which APIs send long IDs
LinkedIn, X and Discord IDs can run to 19 digits. X and Discord send them as strings (X, Discord). Discord’s docs say why: its IDs are up to 64 bits, which would overflow as numbers in some languages. At 64 bits, an ID can even reach 20 digits. LinkedIn’s API uses strings as well, both for plain IDs and for URNs like urn:li:share:1234 (LinkedIn).
In March, a bug report against Claude Code (#37707) said its official Discord plugin turned channel IDs into numbers, and fetching messages failed. It was closed for inactivity on 20 April, so it neither confirms the cause nor shows a fix.
So keep IDs as strings end to end: in your types, your schemas and the text you hand the model. The same goes for any integration that passes records between two systems, not only agent tools.
When an API sends numbers anyway
JavaScript now has a fix. A JSON.parse reviver gets a third argument with each number’s original source text:
const text = '{"id":1234567890123456789}'
const data = JSON.parse(text, (key, value, { source }) =>
key === 'id' && typeof value === 'number' ? source : value
)
data.id // '1234567890123456789'
Three limits (MDN):
- It needs the raw response text. If an SDK has already parsed the response, a large ID may already be rounded, and
BigInt()on that number returns the rounded value, not the ID. - Match the ID fields your schema names, not every number, and check the string against your ID format. JSON numbers can also be fractions or exponents like
1e21. - It needs Chrome 114, Firefox 135, Safari 18.4 or Node 21 and later (compat data). On older runtimes the third argument is missing, and this callback throws.
This source-text reviver is the first of ten new features that shorten agent code. Five are in the language now, two are finished and expected in ES2027, and three are still proposals. Versions below are the first releases that support each feature, from MDN’s compatibility data.
Ships now: ES2025 and ES2026
1. Numeric IDs from other APIs
JSON.parse source text · ES2026 · Chrome 114, Firefox 135, Safari 18.4, Node 21 (compat data).
Before: JSON.parse(text), with the problem above. After: the reviver above.
2. Images for a model API
Uint8Array.prototype.toBase64() · ES2026 · Chrome 140, Firefox 133, Safari 18.2, Node 25 (compat data).
Before, which can hit the engine’s argument limit on large images:
// bytes: Uint8Array
const base64 = btoa(String.fromCharCode(...bytes))
After:
const base64 = bytes.toBase64()
3. State per agent run
Map.prototype.getOrInsert() · ES2026 · Chrome 145, Firefox 144, Safari 26.2, Node 26 (compat data).
Before:
// runs: Map<string, string[]>
if (!runs.has(id)) runs.set(id, [])
runs.get(id).push(step)
After:
runs.getOrInsert(id, []).push(step)
4. Missing permissions
Set.prototype.difference() · ES2025 · Chrome 122, Firefox 127, Safari 17, Node 22 (compat data).
Before:
// needed, granted: Set<string>
const missing = new Set([...needed].filter((s) => !granted.has(s)))
After:
const missing = needed.difference(granted)
It finds what’s missing. Granting it is still your code’s decision.
5. Model text inside a regex
RegExp.escape() · ES2025 · Chrome 136, Firefox 134, Safari 18.2, Node 24 (compat data).
Before: a copied escape pattern, or none.
const text = '$4.99+tax?' // from the model
new RegExp(text).test('Total: $4.99+tax?') // false
After:
new RegExp(RegExp.escape(text)).test('Total: $4.99+tax?') // true
Finished, expected in ES2027
Both are finished proposals at Stage 4, due in the 2027 edition (TC39). Stable Safari doesn’t have them yet, only Safari Technology Preview.
6. Closing the browser when a tool call throws
await using · Chrome 134, Firefox 141, Node 24 (compat data).
Before:
const browser = await chromium.launch()
try {
await runTool(browser)
} finally {
await browser.close()
}
After:
await using browser = await chromium.launch()
await runTool(browser)
Why: miss the finally, and a failed browser task can leave Chromium running on your server. Playwright’s browser closes itself when the scope exits, even on a throw (Playwright).
7. Dates across time zones
Temporal.ZonedDateTime.prototype.add() · Chrome 144, Firefox 139, Node 26 (compat data).
Before:
// t: epoch milliseconds for 15:00 on 21 October 2026 in Berlin
const due = new Date(t + 7 * 864e5) // 14:00 on 28 October in Berlin
After:
const start = Temporal.ZonedDateTime.from('2026-10-21T15:00[Europe/Berlin]')
const due = start.add({ days: 7 }) // 2026-10-28T15:00:00+01:00[Europe/Berlin]
Seven days of milliseconds is 168 hours, and Berlin’s clocks go back on 25 October, so 3pm becomes 2pm. Temporal adds calendar days and keeps 3pm (tz database).
Still proposals
These can still change. Some browsers already ship two of them.
8. Parallel tool calls
Promise.allKeyed · Stage 3 · Firefox 155; not yet in Chrome, Safari or Node (compat data).
Before, with results matched by position:
const [user, orders] = await Promise.all([getUser(id), getOrders(id)])
After, with results by name:
const { user, orders } = await Promise.allKeyed({
user: getUser(id),
orders: getOrders(id)
})
Why: add a tool call to Promise.all, miss the destructuring, and results land in the wrong variables. How parallel calls to one API share a connection is a separate question, covered in which web standard fits which job.
9. Prompts as files
Import Text · Stage 3 · Chrome 155, Firefox 153, Bun 1.1.5, Deno 2.8; not yet in Safari or Node (compat data).
Before:
import { readFileSync } from 'node:fs'
const prompt = readFileSync(new URL('./prompt.md', import.meta.url), 'utf8')
After:
import prompt from './prompt.md' with { type: 'text' }
10. One trace per agent run
AsyncContext · Stage 2, the least mature of the three.
Before: a runId passed through every call, or Node’s AsyncLocalStorage.
import { AsyncLocalStorage } from 'node:async_hooks'
const runId = new AsyncLocalStorage()
runId.run(id, () => agent.start())
// anywhere deeper: log(runId.getStore(), message)
After: AsyncContext, the same idea, meant for every runtime.
const runId = new AsyncContext.Variable()
runId.run(id, () => agent.start())
// anywhere deeper: log(runId.get(), message)
Why: without a run ID on each log line, you can’t tell which run failed. For now it’s a direction to watch, not something to ship.
Runtimes and types
Node 26 runs features 1 to 7 and is scheduled to become LTS on 28 October 2026 (Node.js). Safari has 6 and 7 only in Technology Preview. TypeScript 7.1 beta, announced on 20 October, adds the ES2026 types (TypeScript). Set TypeScript’s lib to what your runtime has, not higher: the types don’t add the features.
Then write your runtime and preferred APIs into CLAUDE.md or AGENTS.md, so your coding agent knows them:
## Runtime
- Node 26. Use Map.prototype.getOrInsert, Set methods, RegExp.escape and Temporal.
- Keep IDs from external APIs as strings.
I first posted this on LinkedIn on 21 October 2026.
On this page
