Temporal API in TypeScript: deadlines and time zones
Being able to use the Temporal API in TypeScript was one of my favorite pieces of news around Node.js 26. Over the years I have seen plenty of code where a deadline was off by an hour because somebody wrote “plus 48 hours” instead of “plus two days” and a daylight saving change happened in between. Bugs like that don’t show up in tests. They show up in production, twice a year.
In my article on Node.js 26 LTS on Lambda I only touched on Temporal briefly. This time we go deeper: step by step, you build the deadline logic for a fictional city library, store the values cleanly in DynamoDB, deploy everything to Lambda with the Serverless Framework and deliberately test across the DST change. Afterwards you will know which Temporal type is meant for what and how to move away from Date or date-fns.
> In short
>
> Temporal replaces Date with dedicated types for instants, calendar dates and times in a time zone. Node.js 26 ships the API without a flag, TypeScript has included the types since version 6.0, and older Lambda runtimes only need a small polyfill. The biggest pitfall is choosing the wrong type: if you store a deadline as a plain date without a time zone, you only move the problem somewhere else.
What you will learn in this article
- Which Temporal type is right for instants, calendar days and deadlines
- How to set up Temporal in TypeScript, Node.js 26 and on Lambda with nodejs24.x
- How to calculate and test deadlines correctly across DST changes
- How to store Temporal values in DynamoDB, including TTL
- How to migrate step by step from Date or date-fns
Why Date so often gets deadlines wrong
Date has been part of JavaScript since 1995 and was closely modeled on an early Java class at the time. You can still tell. Months start at 0, so new Date(2026, 9, 25) is October 25. A Date object is mutable, and if you pass it to a function you may get it back changed. On top of that, Date knows exactly two time zones: UTC and the local time zone of the machine running the code.
That last point is especially tricky in a Serverless setup. A Lambda function runs in UTC by default. Your customers, however, live in Berlin, Vienna or Zurich. Ask Lambda for “today” at 0:30 am German time and you still get yesterday. Lambda is doing everything right here. The real question is: whose “today” do you mean?
The classic example is the DST change. On October 25, 2026, clocks in Germany go back from 3:00 am to 2:00 am, so that day has 25 hours. If you create a deadline of “two days” on October 24 at 10:00 am and calculate it as 48 hours, you end up at 9:00 am on October 26. What you meant was 10:00 am. That is why Temporal clearly separates exact durations in hours from calendar arithmetic in days.
const pickup = Temporal.ZonedDateTime.from("2026-10-24T10:00[Europe/Berlin]");
console.log(pickup.add({ hours: 48 }).toString());
// 2026-10-26T09:00:00+01:00[Europe/Berlin] (exactly 48 hours later)
console.log(pickup.add({ days: 2 }).toString());
// 2026-10-26T10:00:00+01:00[Europe/Berlin] (two calendar days later)
Both results are correct. They simply answer different questions. With Date you have to keep that difference in your head, while Temporal makes you write it down.
Which Temporal type for which purpose?
Temporal is a namespace like Math, not a constructor. It contains several classes, and all objects are immutable: every operation returns a new object. For everyday work you mainly need these types:
| Type | What it describes | Typical example |
| --- | --- | --- |
| Temporal.Instant | Exact point on the timeline, without calendar or time zone | Event timestamp, log entry |
| Temporal.ZonedDateTime | Instant plus time zone, handles summer and winter time | Return deadline “Nov 19 at 6:00 pm in Berlin” |
| Temporal.PlainDate | Calendar date without time and without time zone | Birthday, day of borrowing, holiday |
| Temporal.PlainTime | Wall-clock time without a date | Closing time “18:00” |
| Temporal.PlainDateTime | Date and time without a time zone | Form input before the time zone is known |
| Temporal.Duration | Length of time in years, months, days, hours … | Loan period, gap between two appointments |
There are also PlainYearMonth (for a billing month, say), PlainMonthDay (recurring anniversaries) and Temporal.Now for the current time in various forms.
!Decision tree: need an exact instant? Yes leads to Instant or ZonedDateTime, No to PlainDate or PlainTime
Rule of thumb for choosing a Temporal type
The rule of thumb for choosing a type
For every value I ask myself one single question: do I need to know exactly when this happens in the world? If yes, I need an Instant or a ZonedDateTime. A return deadline belongs in this group, because at 6:01 pm in Berlin it has expired, no matter where the server is. If the answer is no, a plain type is enough. A birthday is on May 3, whatever time zone you happen to be in.
A mistake I see often: deadlines get stored as PlainDateTime because it looks so simple. Then exactly the information you later need for comparisons is missing. PlainDateTime is meant for intermediate steps, for example a form before it is clear which time zone the appointment takes place in.
Setting up the Temporal API in TypeScript
Before you start, a quick look at support. Temporal has reached stage 4 in the TC39 process, the standards committee for JavaScript, which means it is fully specified. Firefox has shipped the API since version 139, Chrome since version 144 and Node.js since version 26. According to the proposal repository, Safari is still missing. TypeScript has included the matching types since version 6.0.
Prerequisites
- Node.js 26 locally (for native Temporal support) or Node.js 22/24 with a polyfill
- TypeScript 6.0 or later; the example was tested with TypeScript 7.0.2
- For deployment: an AWS account and the Serverless Framework v4 (npm i -g serverless)
- Basic knowledge of Lambda and DynamoDB helps but isn’t required
Lambda runtime and polyfill
On Lambda, nodejs26.x is still a public preview, and AWS plans the regular launch for November 2026. Preview runtimes come without the Lambda SLA and are not intended for production use. Until then, use nodejs24.x and load the temporal-polyfill package (MIT license). Its entry point temporal-polyfill/global installs Temporal globally only if the runtime doesn’t already provide it. I checked this in the package’s source code: on Node.js 26 the import simply does nothing. So later you can switch the runtime without touching a single line of code.
Configuring TypeScript
You enable the types via the lib option. Either take all of esnext or only the esnext.temporal part. I prefer the more granular option because it makes clear which new features the project actually uses.
{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024", "esnext.temporal"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["src", "test"]
}
allowImportingTsExtensions and erasableSyntaxOnly let Node.js run the TypeScript files directly. We will use that for the tests in a moment, without any build step.
Step by step: loan deadlines for a library
Our example is a fictional city library with clear rules. A book may be borrowed for 21 days. If the last day falls on a Sunday or a holiday, the return moves to the next opening day. You can return books until 6:00 pm Berlin time. Two days before the deadline a reminder goes out at 9:00 am, and every overdue day costs 50 cents, up to a maximum of 10 euros.
Step 1: Create the project
mkdir temporal-library-loans && cd temporal-library-loans
npm init -y
npm pkg set type=module
npm i temporal-polyfill @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm i -D typescript @types/node @types/aws-lambda
mkdir src test
Then create the tsconfig.json shown above. Next comes a tiny file that loads the polyfill when needed:
// src/temporal.ts
// Loads the polyfill only if the runtime has no native Temporal
// (e.g. nodejs24.x on Lambda). On Node.js 26 this does nothing.
import "temporal-polyfill/global";
Step 2: The deadline logic as pure functions
The rules go into a file without any AWS dependencies. That makes them easy to test and independent of where the code runs later.
// src/loan-rules.ts
import "./temporal.ts";
export const LIBRARY_ZONE = "Europe/Berlin";
export const LOAN_DAYS = 21;
export const CLOSING_TIME = Temporal.PlainTime.from("18:00");
export const FEE_PER_DAY_CENTS = 50;
export const MAX_FEE_CENTS = 1000;
// Holidays when the library is closed (fictional selection)
export const HOLIDAYS = new Set(["2026-12-25", "2026-12-26", "2027-01-01"]);
function isClosed(date: Temporal.PlainDate): boolean {
return date.dayOfWeek === 7 || HOLIDAYS.has(date.toString());
}
// Due date: day of borrowing + 21 days; on a Sunday or holiday, the next opening day
export function dueDate(borrowedOn: Temporal.PlainDate): Temporal.PlainDate {
let due = borrowedOn.add({ days: LOAN_DAYS });
while (isClosed(due)) {
due = due.add({ days: 1 });
}
return due;
}
// Exact deadline: due date at 6:00 pm Berlin time
export function returnDeadline(due: Temporal.PlainDate): Temporal.ZonedDateTime {
return due.toZonedDateTime({ timeZone: LIBRARY_ZONE, plainTime: CLOSING_TIME });
}
// Reminder two calendar days earlier at 9:00 am – wall-clock time, even across DST changes
export function reminderAt(deadline: Temporal.ZonedDateTime): Temporal.ZonedDateTime {
return deadline.subtract({ days: 2 }).with({ hour: 9, minute: 0 });
}
// Overdue days: calendar days between due date and today (never negative)
export function overdueDays(due: Temporal.PlainDate, today: Temporal.PlainDate): number {
const days = due.until(today, { largestUnit: "days" }).days;
return Math.max(0, days);
}
export function lateFeeCents(due: Temporal.PlainDate, today: Temporal.PlainDate): number {
return Math.min(overdueDays(due, today) * FEE_PER_DAY_CENTS, MAX_FEE_CENTS);
}
// "Today" from the library's point of view – not from the Lambda server's (UTC)
export function libraryToday(now: Temporal.Instant): Temporal.PlainDate {
return now.toZonedDateTimeISO(LIBRARY_ZONE).toPlainDate();
}
A few spots deserve a second look. The due date is a PlainDate, because “21 days later” is purely a calendar question. dayOfWeek follows the ISO standard from 1 (Monday) to 7 (Sunday), so the Sunday-is-zero confusion of Date is finally gone. Only in returnDeadline do time and time zone come in, turning the calendar day into an actual instant.
For the reminder, we deliberately calculate in calendar days and then set the time with with(). If the DST change falls between reminder and deadline, it still stays at 9:00 am wall-clock time. That is exactly what people expect when a reminder is supposed to arrive “at nine in the morning”.
The libraryToday function looks harmless but matters a lot. It receives an Instant and converts it into the library’s time zone. As a result, “today” is always Berlin’s today, even though Lambda runs in UTC. And because the instant comes from outside, you can simulate any time you like in tests.
Step 3: Storing Temporal values in DynamoDB
DynamoDB has no date type. So you have to decide in which form to store the values, and that decision matters more than it seems. My suggestion:
- Plain calendar dates as ISO strings like 2026-11-19. They are readable and sort lexicographically.
- Instants as UTC strings like 2026-11-19T17:00:00Z. That way comparisons and range queries on a sort key work.
- The time zone separately as an IANA name like Europe/Berlin, so you can restore local time for display.
- For DynamoDB TTL, an additional number with epoch seconds, because TTL ignores anything that isn’t a Number attribute.
// src/storage.ts
import "./temporal.ts";
export interface Loan {
loanId: string;
bookId: string;
memberId: string;
borrowedOn: Temporal.PlainDate;
due: Temporal.PlainDate;
deadline: Temporal.ZonedDateTime;
}
export interface LoanItem {
pk: string;
bookId: string;
memberId: string;
borrowedOn: string; // "2026-10-29" – plain date, no time
dueDate: string; // "2026-11-19"
deadlineAt: string; // "2026-11-19T17:00:00Z" – UTC, sortable
timeZone: string; // "Europe/Berlin" – for display
expiresAt: number; // epoch seconds for DynamoDB TTL
}
const KEEP_AFTER_DEADLINE = { days: 180 };
export function toItem(loan: Loan): LoanItem {
return {
pk: `LOAN#${loan.loanId}`,
bookId: loan.bookId,
memberId: loan.memberId,
borrowedOn: loan.borrowedOn.toString(),
dueDate: loan.due.toString(),
deadlineAt: loan.deadline.toInstant().toString(),
timeZone: loan.deadline.timeZoneId,
expiresAt: Math.floor(loan.deadline.add(KEEP_AFTER_DEADLINE).epochMilliseconds / 1000),
};
}
export function fromItem(item: LoanItem): Loan {
return {
loanId: item.pk.replace("LOAN#", ""),
bookId: item.bookId,
memberId: item.memberId,
borrowedOn: Temporal.PlainDate.from(item.borrowedOn),
due: Temporal.PlainDate.from(item.dueDate),
deadline: Temporal.Instant.from(item.deadlineAt).toZonedDateTimeISO(item.timeZone),
};
}
Why not just store the string of a ZonedDateTime, i.e. 2026-11-19T18:00:00+01:00[Europe/Berlin]? It does contain everything, but it doesn’t sort reliably once values with different offsets end up in the same column. The UTC instant doesn’t have that problem. You can read more about TTL and expiry dates in my post DynamoDB TTL: build seat holds that expire.
Step 4: Lambda handler and serverless.yml
The handler is intentionally lean. It reads the book and member from the request, calculates date, deadline and reminder and writes the record to DynamoDB.
// src/handler.ts
import { randomUUID } from "node:crypto";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { dueDate, libraryToday, reminderAt, returnDeadline } from "./loan-rules.ts";
import { toItem, type Loan } from "./storage.ts";
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.LOANS_TABLE ?? "";
export async function createLoan(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
const body = JSON.parse(event.body ?? "{}") as { bookId?: string; memberId?: string };
if (!body.bookId || !body.memberId) {
return { statusCode: 400, body: JSON.stringify({ message: "bookId and memberId are required" }) };
}
const borrowedOn = libraryToday(Temporal.Now.instant());
const due = dueDate(borrowedOn);
const deadline = returnDeadline(due);
const loan: Loan = {
loanId: randomUUID(),
bookId: body.bookId,
memberId: body.memberId,
borrowedOn,
due,
deadline,
};
await db.send(new PutCommand({ TableName: TABLE, Item: toItem(loan) }));
return {
statusCode: 201,
body: JSON.stringify({
loanId: loan.loanId,
dueDate: due.toString(),
deadline: deadline.toString(),
reminder: reminderAt(deadline).toString(),
}),
};
}
> 🎬 Flow of a book loan with Temporal – this animated diagram only runs on my site: watch it on sascha-ms.de
For the infrastructure I am using the Serverless Framework here. Since version 4 it builds TypeScript itself with esbuild, so no extra build configuration is needed.
# serverless.yml
service: library-loans
frameworkVersion: "4"
provider:
name: aws
runtime: nodejs24.x # switch to nodejs26.x once it is GA
region: eu-central-1
architecture: arm64
memorySize: 256
environment:
LOANS_TABLE: ${self:service}-${sls:stage}-loans
TZ: UTC # Lambda default; library time comes from the code
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:PutItem
Resource:
- !GetAtt LoansTable.Arn
functions:
createLoan:
handler: src/handler.createLoan
events:
- httpApi:
path: /loans
method: post
resources:
Resources:
LoansTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service}-${sls:stage}-loans
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expiresAt
Enabled: true
The IAM role may only call PutItem on exactly this table. The handler needs nothing more. Deploy and try it out:
serverless deploy
curl -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/loans" \
-H "Content-Type: application/json" \
-d '{"bookId":"book-42","memberId":"reader-7"}'
If you borrow on October 29, 2026, the response looks roughly like this (the loanId will of course differ):
{
"loanId": "3f2c9a4e-8b1d-4c57-9e0a-5d6f7a8b9c01",
"dueDate": "2026-11-19",
"deadline": "2026-11-19T18:00:00+01:00[Europe/Berlin]",
"reminder": "2026-11-17T09:00:00+01:00[Europe/Berlin]"
}
Step 5: Testing across the DST change
Now comes the part I consider most important for date logic. Tests with “today plus three days” are almost always green and prove little. They only become meaningful with fixed dates around the DST change, holidays and the day boundary between UTC and Berlin.
// test/loan-rules.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { dueDate, lateFeeCents, libraryToday, overdueDays, reminderAt, returnDeadline } from "../src/loan-rules.ts";
const day = (s: string) => Temporal.PlainDate.from(s);
test("due date is 21 days after borrowing", () => {
assert.equal(dueDate(day("2026-10-29")).toString(), "2026-11-19");
});
test("holidays and Sundays move to the next opening day", () => {
// Dec 25 holiday, Dec 26 holiday, Dec 27 Sunday
assert.equal(dueDate(day("2026-12-04")).toString(), "2026-12-28");
});
test("deadline stays at 6:00 pm Berlin time, even after the DST change", () => {
const deadline = returnDeadline(day("2026-10-26"));
assert.equal(deadline.toString(), "2026-10-26T18:00:00+01:00[Europe/Berlin]");
assert.equal(deadline.toInstant().toString(), "2026-10-26T17:00:00Z");
});
test("reminder at 9:00 am wall-clock time across the DST change", () => {
const reminder = reminderAt(returnDeadline(day("2026-10-26")));
assert.equal(reminder.toString(), "2026-10-24T09:00:00+02:00[Europe/Berlin]");
});
test("overdue days and capped fee", () => {
assert.equal(overdueDays(day("2026-11-19"), day("2026-11-23")), 4);
assert.equal(overdueDays(day("2026-11-19"), day("2026-11-10")), 0);
assert.equal(lateFeeCents(day("2026-11-19"), day("2026-11-23")), 200);
assert.equal(lateFeeCents(day("2026-11-19"), day("2026-12-31")), 1000);
});
test("“today” is based on Berlin, not UTC", () => {
// 23:30 UTC is already the next day in Berlin
assert.equal(libraryToday(Temporal.Instant.from("2026-10-29T23:30:00Z")).toString(), "2026-10-30");
});
You can run the tests directly with Node.js, without a compile step:
npx tsc --noEmit
node --test test/*.test.ts
Expected result: all tests pass. I ran them on Node.js 26.13 with native Temporal and on Node.js 22 with the polyfill, both times with identical results. The repository also contains a second test for the round trip through DynamoDB and the conversion from Date, which I leave out here to save space.
Summer and winter time: what Temporal decides behind the scenes
Twice a year there are times in Berlin that are ambiguous. In spring an hour is missing: on March 28, 2027 the clock jumps from 2:00 am to 3:00 am, so “2:30 am” doesn’t exist that day. In autumn an hour happens twice: on October 25, 2026, 2:30 am occurs once in summer time and once in winter time.
Temporal solves this with the disambiguation option. The default value compatible behaves like Date: inside the gap the time is pushed forward, and in the repeated hour the earlier one wins. With earlier or later you pick a variant on purpose, and with reject Temporal throws a RangeError.
// Spring gap: 2:30 am does not exist
Temporal.ZonedDateTime.from("2027-03-28T02:30[Europe/Berlin]").toString();
// 2027-03-28T03:30:00+02:00[Europe/Berlin]
// Repeated hour in autumn: deliberately pick the second 2:30 am
Temporal.PlainDateTime.from("2026-10-25T02:30")
.toZonedDateTime("Europe/Berlin", { disambiguation: "later" })
.toString();
// 2026-10-25T02:30:00+01:00[Europe/Berlin]
// Often the best choice for booking systems: an error beats a silent shift
Temporal.PlainDateTime.from("2027-03-28T02:30")
.toZonedDateTime("Europe/Berlin", { disambiguation: "reject" }); // RangeError
Something similar applies to month arithmetic. January 31 plus one month gives February 28 with the default overflow: "constrain". If you prefer an error, pass { overflow: "reject" }. For subscriptions and contract terms this is a business decision that you should clarify with the domain experts instead of leaving it to chance.
Migrating from Date and date-fns
Hardly any project can switch everything overnight. It doesn’t have to. I would use Temporal first in new business logic and convert at the edges, wherever older libraries, ORMs or SDKs still expect or return Date.
// src/legacy.ts
import "./temporal.ts";
// Existing code still returns Date objects? This converts them without loss.
export function fromLegacyDate(value: Date): Temporal.Instant {
return Temporal.Instant.fromEpochMilliseconds(value.getTime());
}
// For libraries that still expect Date (e.g. older SDKs)
export function toLegacyDate(value: Temporal.Instant): Date {
return new Date(value.epochMilliseconds);
}
Going through epoch milliseconds is lossless, because Date only knows milliseconds anyway. Native runtimes and the polyfill also offer Date.prototype.toTemporalInstant(). I still prefer the explicit function, because it shows in the code where the old world ends.
With date-fns, Day.js or Luxon it pays to look at what the library is actually used for. Often it is a handful of functions like addDays, differenceInDays or format. Temporal replaces the first two directly with add() and until(). For formatting there is toLocaleString(), which uses Intl.DateTimeFormat internally. Temporal.PlainDate.from("2026-11-19").toLocaleString("en-US", { dateStyle: "full" }), for example, returns “Thursday, November 19, 2026”. Running both side by side is fine for a transition period. The only important thing is that a function works either with Temporal or with Date, not with both.
Common mistakes with the Temporal API in TypeScript
A few pitfalls kept coming up while I was trying things out.
- Comparing with === or <: Two Temporal objects with the same value are still different objects. Use equals() or the static compare() method. Comparing with < deliberately throws a TypeError.
- Temporal.Now deep inside business logic: Then you can’t control time in tests. Get the current time at the edge, for example in the handler, and pass it on as a parameter.
- JSON there, but not back: JSON.stringify turns Temporal objects into strings. When reading them back, you have to convert them with from() yourself, or you keep calculating with strings.
- structuredClone and workers: Temporal objects can’t be structured-cloned, and Node.js throws a DataCloneError. Pass strings and rebuild the objects on the other side.
- Storing an offset instead of a time zone: +01:00 is not a time zone. In summer, Berlin uses +02:00. If you only know the offset, you can’t calculate correctly into the future.
Cost, performance and security
The polyfill has a price, but it is modest. In my example, the minified handler including the polyfill weighs about 60 KB, not counting the AWS SDK, which is part of the runtime. For a Lambda cold start that hardly matters. Once nodejs26.x is generally available, you can remove the polyfill and save those bytes entirely.
DynamoDB TTL costs nothing extra. Expired items are deleted without consuming write capacity, though not immediately: according to AWS, typically within a few days. Queries should therefore filter out expired items themselves. With global tables, however, the replicated deletes in other regions do consume write capacity.
Two notes on security. Always validate date values from requests with from() inside a try block, because invalid input throws a RangeError. And don’t accept time zones from requests unchecked; compare them against a list of allowed zones. For scheduled reminders, EventBridge Scheduler is a good fit, since it understands cron expressions with a time zone. My EventBridge cron explainer shows how such expressions are built.
My take
I consider Temporal one of the most useful additions JavaScript has received in recent years. You could build correct code with Date, too. What makes Temporal valuable is that it forces the right questions. If you have to choose between PlainDate and ZonedDateTime, you automatically think about what a value means in business terms.
At the same time, I wouldn’t rebuild everything at once. Existing code with date-fns that has been running reliably for years is not an emergency. New business logic, though, I would write with Temporal starting now, on Lambda with the polyfill for the time being. For frontends that also serve Safari, the polyfill remains mandatory for a while anyway. One point where I stay skeptical: the API is large. Teams should agree on a few types and clear storage formats, otherwise you quickly end up with a new kind of sprawl, just with more modern names.
FAQ
Can I use the Temporal API in production already?
Yes. Temporal is fully standardized and available without a flag in Node.js 26, Chrome and Firefox. For Safari and older Node versions such as nodejs24.x on Lambda, use a polyfill like temporal-polyfill.
Which TypeScript version do I need for Temporal?
The types are built in from TypeScript 6.0. You enable them with "lib": ["esnext"], with "target": "esnext" or specifically with esnext.temporal.
What is the difference between Instant and ZonedDateTime?
Both describe an exact point in time. An Instant, however, has no time zone and therefore no calendar. ZonedDateTime also knows the time zone in which the instant is viewed, so it can calculate correctly with days, months and DST changes.
Does Temporal replace date-fns, Luxon or Day.js?
For arithmetic, comparisons and time zones, in many cases yes. For formatting, Temporal uses Intl.DateTimeFormat. Special features like relative times (“3 days ago”) are covered by Intl.RelativeTimeFormat or still by a library.
Your next step
Find a place in your project where a deadline or appointment is calculated with Date today, and write a test for October 25 or March 28. If it passes, great. If not, you have found your first candidate for Temporal.
How do you handle time zones in your Lambda functions so far: everything in UTC with conversion in the frontend, or do you already calculate with local time in the backend? Let me know in the comments.
Time zones matter not only for deadlines but also for triggering jobs. I show how to start cron jobs reliably at German local time in EventBridge Scheduler time zone: cron at local time.
Sources
- TC39: Temporal proposal (stage 4, implementation status)
- MDN: Temporal
- Node.js: release notes v26.0.0
- TypeScript blog: Announcing TypeScript 6.0
- AWS: Lambda runtimes (nodejs26.x as public preview)
- temporal-polyfill on GitHub
- AWS: Time to Live in DynamoDB
This post was created with AI assistance.
---
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
1 reaction
Stop Spreading Iterables Into Arrays to Join Them. Iterator.concat() Is Here
TL;DR: Iterator.concat(a, b, c) walks any iterables one after another and returns a lazy iterator. No temporary array, no hand-written generator, and it has been Baseline Newly available since March 2026.
The array you build just to throw away
You have an array, a Set, and a generator. You want to loop over all of them in order. So you write the line everyone writes:
for (const item of [...pinned, ...recentSet, ...loadMore()]) {
render(item);
}
It works. It also copies every item into a brand new array, runs the generator to the very end, and then hands the array to the garbage collector a moment later. If you only needed the first few items, you paid for all of them.
The careful version is a generator with yield*:
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
Correct, lazy, and copy-pasted into a utils folder near you.
What it is
Iterator.concat() is that generator, built into the language. It takes any number of iterables and returns an iterator that yields from each one in turn:
const array = [1, 2, 3];
const set = new Set([4, 5]);
function* gen() {
yield 6;
yield 7;
}
console.log([...Iterator.concat(array, set, gen())]);
// [1, 2, 3, 4, 5, 6, 7]
The result is a real iterator, so the iterator helpers work on it directly:
const evensTimesTen = Iterator.concat([1, 2, 3], new Set([4, 5, 6]))
.filter((n) => n % 2 === 0)
.map((n) => n * 10)
.toArray();
console.log(evensTimesTen); // [20, 40, 60]
Maps are iterables of [key, value] pairs, which makes "defaults, then overrides" a one-liner:
const defaults = new Map([["theme", "light"], ["lang", "en"]]);
const user = new Map([["theme", "dark"]]);
const settings = new Map(Iterator.concat(defaults, user));
console.log(Object.fromEntries(settings)); // { theme: "dark", lang: "en" }
Later keys win, because that is how the Map constructor already behaves.
The realistic one: take only what you need
Here is where laziness pays. Two sources of 1000 rows each, and a UI that shows the first three:
let pulled = 0;
function* page(start) {
for (let i = start; i < start + 1000; i++) {
pulled++;
yield i;
}
}
const spread = [...page(0), ...page(1000)].slice(0, 3);
console.log(spread, pulled); // [0, 1, 2] 2000
pulled = 0;
const lazy = Iterator.concat(page(0), page(1000)).take(3).toArray();
console.log(lazy, pulled); // [0, 1, 2] 3
Same answer. The spread version produced 2000 rows to keep 3. The concat version produced 3. Replace "row" with "parsed log line" or "decoded image" and that gap stops being academic.
It is also lazy about opening each source. Logging every [Symbol.iterator]() call on two iterables gave this order:
created
open a
next 1
next 2
open b
next 3
Nothing is opened when you call concat, and b is not touched until a runs out. That also means infinite sources are fine, as long as you stop early:
function* naturals() {
let i = 1;
while (true) yield i++;
}
console.log(Iterator.concat(["start"], naturals()).take(5).toArray());
// ["start", 1, 2, 3, 4]
Can I use it?
| Where | Status |
|---|---|
| Baseline | Newly available since March 2026 |
| Chrome and Edge | 146 |
| Firefox | 147 |
| Safari (macOS and iOS) | 26.4 |
| Node.js 24 | Not yet: typeof Iterator.concat is "undefined" in v24.11.0 |
| Spec | TC39 Iterator Sequencing proposal, Stage 4 |
Everything in this post ran in Chrome 156. If you need it in Node today, keep the five-line generator from above as a fallback.
Gotchas
1. Strings are rejected, even though they are iterable. Iterator.concat([1], "ab") throws TypeError: Iterator.concat called on non-object right away, at the call, not later during iteration. null throws the same error. Wrap a string first: Iterator.from("bc") or [..."bc"] both worked.
2. Bare iterators need Iterator.from(). An object with only a next() method is not iterable, and passing one threw TypeError: #<Object> is not iterable. Built-in iterators like [1, 2, 3].values() are fine, because they are iterable too.
3. The result is single use. Spreading the same concat result twice gave [1, 2, 3] the first time and [] the second. It is an iterator, not an array, so call Iterator.concat again if you need a second pass.
4. Breaking early closes what it opened. With two generators that log in finally, a for...of that broke on the first item of the second one logged closed a when a ran out and closed b on the break. Your cleanup code runs, which is exactly what you want.
Wrap-up
Iterator.concat() is the yield* helper you kept rewriting, now with iterator helpers attached. Next time you type [...a, ...b] only to loop over it once, try Iterator.concat(a, b) instead.
Sources
- Iterator.concat() (MDN)
- Iterator Sequencing proposal (TC39)
- Finished proposals (TC39)
- Iterator browser compatibility data (MDN BCD)
This article was written with the help of AI and checked against the sources above.
2 comments
It would be interesting if browsers supported more languages but I don't mind the monoculture. I have spent a lot of time investing in languages, frameworks and tools, so much of that learning is both useless and worthless today. Most of what I have mastered isn't viable to use for anything in 2026.
About ten years ago I deliberately started doubling-down on JavaScript instead, it's good-enough for practically any use case and I've been able to leverage it for web, mobile, desktop, server and games. There are things other languages can do better, but I love not having to install them and manage their different versions and dependencies, and stay familiar with their idiomatic approaches, and keep up-to-date with their evolution.
There are outcomes where a monoculture can be bad, but structurally I think JavaScript is in a good place - open standard, open governance, open to proposals, massive open source culture.
github.com/tc39/ecma262/blob/main/CONTRIBUTING.md
Why JavaScript Silently Changes Large JSON Numbers
---
title: Why JavaScript Silently Changes Large JSON Numbers
published: true
description: JSON.parse rounds integers above 2^53 without an error. A reproducible example, why it happens, how to detect it, and how to read big IDs exactly.
tags: javascript, json, webdev, programming
canonical_url: curlyjson.com
---
JavaScript can silently change large integers when it parses perfectly valid JSON. That matters for database IDs, API responses, and the tools you use to look at them. Run this in Node or a browser console:
const order = JSON.parse('{"orderId": 9007199254740993, "amount": 1.50}');
console.log(order.orderId);
console.log(JSON.stringify(order));
Output:
9007199254740992
{"orderId":9007199254740992,"amount":1.5}
The ID is now off by one. There's no exception and no warning, and the result is still valid JSON. If you pass that ID to the next request, you're asking about a different order.
Where the digit goes
JSON's grammar puts no limit on the size of a number. JSON.parse does: it turns every number into a JavaScript Number, which is an IEEE 754 double. A double has a 53-bit significand, so every integer up to 2^53 − 1 is stored exactly:
Number.MAX_SAFE_INTEGER // 9007199254740991
Above that, only some integers fit. Between 2^53 and 2^54, a double can only hold even integers. 9007199254740993 is odd, so it's rounded to a neighbour, 9007199254740992. The same thing happens to the literal in your source code, before any JSON is involved:
9007199254740993 === 9007199254740992 // true
Further up the range the gaps get wider, so the error gets bigger:
JSON.parse('{"big": 12345678901234567890}').big // 12345678901234567000
Not every large integer changes: 9007199254740992 is stored exactly. The trouble is that you can't tell from the parsed value whether it was rounded.
Two related changes come from the same step. 1.50 comes back as 1.5: the value is the same, but once it's a Number there's no record of the trailing zero, so JSON.stringify can't reproduce what you sent. And a number too large for a double becomes Infinity, which JSON can't represent, so it's written out as null:
JSON.stringify(JSON.parse('{"d": 1e400}')) // '{"d":null}'
| JSON input | After JSON.parse + JSON.stringify | What changed |
|---|---|---|
| 9007199254740993 | 9007199254740992 | Integer rounded |
| 12345678901234567890 | 12345678901234567000 | Integer rounded |
| 1.50 | 1.5 | Trailing zero dropped, value unchanged |
| 1e400 | null | Too large: parsed as Infinity, which JSON can't represent |
| "9007199254740993" | "9007199254740993" | Nothing: strings are kept as written |
JSON itself doesn't limit number size. RFC 8259 lets implementations set their own limits, and notes that integers between −(2^53 − 1) and 2^53 − 1 are the ones every implementation using double-precision numbers will agree on. The rounding comes from JavaScript's Number, not from JSON.
What's usually affected
- 64-bit integer IDs (BIGINT) once their values pass 2^53, for example IDs generated from timestamps or random bits rather than a small counter.
- Snowflake-style IDs. X (Twitter) and Discord both use them, and both are far above 2^53.
- Numbers produced by backends with native 64-bit integers. The server serializes them correctly; the precision is lost only when JavaScript parses them.
Some APIs work around it. Discord's API reference says snowflakes are always returned as strings in the HTTP API, to avoid integer overflow in some languages. Twitter's API returned every ID twice, as the number id and the string id_str, and its documentation told JavaScript developers to use id_str.
Checking whether a value may have been rounded
After parsing, Number.isSafeInteger tells you whether an integer is inside the exact range:
Number.isSafeInteger(order.orderId) // false
Number.isSafeInteger(9007199254740991) // true
false means the value may have been rounded, not that it was: 9007199254740992 is itself representable. To know for sure you need the original text, and newer runtimes give it to you. The reviver function passed to JSON.parse now receives a third argument, context, whose source property is the number exactly as it was written. This comes from the TC39 proposal JSON.parse source text access, which is at Stage 4.
This function finds integers outside JavaScript's safe integer range and shows their original text alongside the parsed value:
function unsafeIntegers(text) {
const found = [];
JSON.parse(text, (key, value, context) => {
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value))
found.push({ key, source: context.source, parsed: value });
return value;
});
return found;
}
unsafeIntegers('{"id": 9007199254740993, "user": {"id": 42}, "ids": [12345678901234567890]}');
Output in Node 23:
[
{ key: 'id', source: '9007199254740993', parsed: 9007199254740992 },
{
key: '0',
source: '12345678901234567890',
parsed: 12345678901234567000
}
]
The second key is '0' because the value is the first element of the ids array. In runtimes that don't support the proposal, context is undefined, so check your target environments before relying on it.
Keeping large integers exact
Send them as strings. If you control the API, "orderId": "9007199254740993" is the most robust fix, because it works in every client and language. This is what Discord does.
Convert them in a reviver. This approach requires a runtime that supports JSON.parse source text access. With it, you can turn a specific field into a BigInt:
const text = '{"orderId": 9007199254740993, "amount": 1.50}';
const exact = JSON.parse(text, (key, value, context) =>
key === 'orderId' ? BigInt(context.source) : value);
exact.orderId // 9007199254740993n
JSON.stringify refuses to write a BigInt:
TypeError: Do not know how to serialize a BigInt
The same proposal adds JSON.rawJSON, which lets a replacer emit the digits unchanged:
JSON.stringify(exact, (key, value) =>
typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value);
// '{"orderId":9007199254740993,"amount":1.5}'
The ID survives the round trip and amount still loses its trailing zero, because the reviver only touched orderId. MDN lists JSON.rawJSON as Baseline 2025, so it's available in current browsers but not in older ones.
Use a parser built for it. Libraries such as json-bigint ("JSON.parse with bigints support") and lossless-json ("Parse JSON without risk of losing numeric information") parse numbers without going through Number. Check how each one represents numbers before choosing; their APIs differ.
A quick test for JSON tools
Formatters, viewers and diff tools often call JSON.parse and JSON.stringify internally. To see whether one does, paste this:
{ "a": 9007199254740993, "b": 12345678901234567890, "c": 1.50, "d": 1e400 }
A tool built on the standard functions gives back:
{"a":9007199254740992,"b":12345678901234567000,"c":1.5,"d":null}
If the numeric text survives unchanged, that's a good sign the tool preserves number precision for this test. It doesn't guarantee every operation is lossless, so test the transformations you actually use.
References
- MDN: JSON.parse()
- MDN: Number.MAX_SAFE_INTEGER
- MDN: JSON.rawJSON()
- TC39: JSON.parse source text access
---
Written with AI assistance. Every code sample was run in Node 23, and the outputs shown are the actual results.
1 comment
Discord の ID を JSON.parse したら末尾が 063 から 060 になった。Snowflake は BigInt で読む
背景
自分のサイトに、Discord や X(旧Twitter)の ID から作成日時を取り出すツールを置いています。
作り始めたとき、テスト用の JSON を JSON.parse して ID を取り出したら、末尾の数字が変わっていました。
知識としては知っていたのですが、自分で踏むまでは他人事でした…
Snowflake は、Twitter が考えた ID の作り方だ。
64 ビットの整数の上のほうにミリ秒単位の時刻、下のほうにどのサーバーで何番目に作ったかを詰める。
Discord も同じ形で、時刻の起点(エポック)だけが違う。
結論
Snowflake ID は Number で持てません。
- JSON.parse で数値のまま読むと、下位の桁が丸められて別の ID になる
- 文字列のまま受け取り、ビット演算は BigInt で行う
- Node.js v24 なら、JSON.parse の reviver で元の数字の文字列も取れる
JavaScript の Number は小数を含む64ビットの浮動小数点数で、整数として正確に扱えるのは Number.MAX_SAFE_INTEGER(9007199254740991、約 9×10^15)までである。
Snowflake は 18〜19 桁あるので、ここを普通に超えます。
試したこと
Discord の開発者ドキュメントに載っている ID の例 175928847299117063 を使いました。
docs.discord.com
API から返ってくる JSON を想定して、id を数値、id_str を文字列で持たせた文字列 raw を作り、そのまま JSON.parse しています(Node.js v24.11.1)。
const raw = '{"id":175928847299117063,"id_str":"175928847299117063"}';
console.log(JSON.parse(raw));
結果は下記でした。
{ id: 175928847299117060, id_str: '175928847299117063' }
数値で読んだ id だけ、末尾が 063 から 060 になっています。
エラーも警告も出ないので、比較して初めて気づきます。
日時だけは合ってしまう
ややこしいのは、壊れた数値から日時を取り出しても、ほぼ正しく見えることです。
Discord の Snowflake は、右に 22 ビットずらした値がエポック(2015-01-01 00:00:00 UTC、Unixミリ秒で 1420070400000)からのミリ秒だ。
下記は、壊れた Number と、文字列から作った BigInt の両方で日時を出したものです。
4194304 は 2 の 22 乗で、Number には >> で64ビットのシフトができないので割り算で代用しています。
const s = '175928847299117063'; // id_str(文字列)
const EPOCH = 1420070400000; // Discord のエポック
const viaNumber = Math.floor(Number(s) / 4194304) + EPOCH;
const id = BigInt(s);
const viaBigInt = Number(id >> 22n) + EPOCH;
console.log(new Date(viaNumber).toISOString()); // 2016-04-30T11:18:25.796Z
console.log(new Date(viaBigInt).toISOString()); // 2016-04-30T11:18:25.796Z
console.log((id >> 17n) & 0x1fn, (id >> 12n) & 0x1fn, id & 0xfffn); // 1n 0n 7n
日時は両方とも 2016-04-30 11:18:25.796 UTC で、ドキュメントの例と同じになりました。
丸められるのは下位の数ビットだけで、時刻は上位に入っているので、たまたま残るという話です。
ただ、最後の行で取り出しているワーカー番号・プロセス番号・連番(ドキュメントでは 1 / 0 / 7)は下位にあるので、Number 経由だと正しく出ません。
それに、ID 自体が別の値になっているので、データベースで探しても見つからないです。
私のツールも、入力を BigInt にしてからシフトしています。
hashitosystem.com
JSON から ID を正しく取り出す
いちばん楽なのは、API が文字列でも返している項目(X の id_str など)を使うことです。
Discord の API は最初から ID を文字列で返すので、こちらは数値になる心配がありません。
自分で受け取る JSON が数値しか持っていないときは、JSON.parse の reviver(読み込み中に値を差し替える第2引数の関数)が使えます。
Node.js v24 では、reviver の第3引数 context.source に、数値に変換する前の元の文字列が入っていました。
const raw = '{"id":175928847299117063,"id_str":"175928847299117063"}';
const obj = JSON.parse(raw, (key, value, context) =>
key === 'id' && context && context.source ? BigInt(context.source) : value
);
console.log(obj); // { id: 175928847299117063n, id_str: '175928847299117063' }
key は今読んでいる項目名で、id のときだけ元の文字列から BigInt を作っています。
context が無い古い環境では value をそのまま返すので、少なくとも落ちはしません(その場合は丸められた値になります)。
この第3引数は TC39 の提案(JSON.parse source text access)で入ったもので、古いブラウザでは使えないかもしれません。
tc39/proposal-json-parse-with-source
BigInt はそのままだと JSON.stringify で例外になるので、保存や送信のときは String(id) で文字列に戻しておくのが無難です。
Snowflake ID は数字に見えても数値として扱わない。
受け取りは文字列、計算は BigInt、と分けておけば、桁が化ける事故は起きません。
---
> 本記事はAI補助で執筆した、個人開発の紹介記事です。
JSON.parse Source Text Access: Stop Losing Precision on Big IDs
Your API returns an order ID of 9007199254740993. Your frontend logs 9007199254740992. Nobody touched it. JSON.parse quietly rounded it, because every JSON number becomes a JavaScript double, and doubles only hold integers exactly up to Number.MAX_SAFE_INTEGER (9007199254740991). Anything bigger gets snapped to the nearest representable value before your code ever sees it.
The usual workarounds are all a little sad. Ask the backend to send IDs as strings (id_str, if you remember the old Twitter API). Pull in a third-party parser like json-bigint. Or run a regex over the raw response to wrap long numbers in quotes before parsing, and hope nothing in a string field looks like a number. The platform now has a real fix: the JSON.parse source text access proposal, which adds a context argument to revivers and a matching JSON.rawJSON() for serialization. It reached Stage 4 at TC39 in late 2025, and it is already Baseline: Chrome 114, Firefox 135, Safari 18.4, Node 21, Deno 1.33, and Bun 1.1.43 all ship it.
The reviver gets a third argument
A reviver has always received (key, value). It now receives (key, value, context), and for primitive values context.source is the exact slice of JSON text that produced value. The number was still parsed into a lossy double, but you get the original digits next to it, so you can decide what it should become.
const json = '{"orderId": 9007199254740993, "total": 42.5}';
const order = JSON.parse(json, (key, value, context) => {
if (key === "orderId") {
// value is already rounded; context.source is not
return BigInt(context.source);
}
return value;
});
order.orderId; // 9007199254740993n
order.total; // 42.5
Keying off property names works when you control the schema, but a generic version is more useful in a shared fetch helper. Promote only integers that actually lost precision, and leave everything else alone:
const INTEGER = /^-?\d+$/;
export function preciseReviver(key, value, context) {
if (
typeof value === "number" &&
!Number.isSafeInteger(value) &&
context?.source &&
INTEGER.test(context.source)
) {
return BigInt(context.source);
}
return value;
}
const res = await fetch("/api/orders");
const data = JSON.parse(await res.text(), preciseReviver);
Two details there matter. Response.json() doesn't accept a reviver, so you read the body as text and parse it yourself. And the optional chaining on context?.source is deliberate: context.source only exists for primitives. When the reviver is called for an object or array, source is absent, because there's no single meaningful slice of text to give you once the children have already been revived.
Writing it back out with JSON.rawJSON
Parsing is half the problem. Hand that BigInt to JSON.stringify and you get TypeError: Do not know how to serialize a BigInt. Convert it to a Number first and you're back to rounding. JSON.rawJSON() closes the loop: it wraps a piece of JSON text that stringify will emit verbatim.
function preciseReplacer(key, value) {
return typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value;
}
JSON.stringify({ orderId: 9007199254740993n }, preciseReplacer);
// '{"orderId":9007199254740993}'
The output is a real JSON number, not a quoted string, so a Go or Java backend expecting an int64 reads it without any schema change. JSON.rawJSON is strict about what it accepts: the text must be valid JSON for a primitive, so JSON.rawJSON('[1, 2]') or a string containing a raw line break throws a SyntaxError. The returned object is frozen with a null prototype, and JSON.isRawJSON() tells you if you're holding one.
Decimals, not just big integers
Money is the other place this pays off. 0.1 parsed as a double is not exactly one tenth, and a payments API that returns "amount": 19.99 hands you a value that's already slightly wrong before your decimal library sees it. With context.source you can feed the original digits straight into whatever arbitrary-precision type you use:
import Decimal from "decimal.js";
const invoice = JSON.parse(body, (key, value, context) =>
key === "amount" ? new Decimal(context.source) : value,
);
On the way out, JSON.rawJSON(amount.toFixed(2)) writes 19.99 as an unquoted number again.
When not to reach for it
A reviver runs once per value in the document, so it is measurably slower than a plain JSON.parse on large payloads. If your IDs are already strings, or every number fits in the safe range, you don't need any of this. In TypeScript, older lib definitions may not know about the third reviver argument or JSON.rawJSON yet, so you might need a small ambient declaration until your lib setting catches up. For older browsers, core-js ships a polyfill for both halves.
The bigger change is where the fix lives. Precision used to be something you negotiated with the backend team or patched with a dependency. Now it's a few lines in the one fetch helper every request already goes through. MDN's pages on JSON.parse() and JSON.rawJSON() have the full reference, and the TC39 proposal covers the design reasoning.
1 reaction
I want bun to have struct support. So here a draft into Webkit. With bun looking to the future and the need the support for high preforming JavaScript. Maybe if Bun adopts then this could help push the spec forward
WebKit#904
Stage 2 Proposal
tc39/proposal-structs
I hope @jarredsumner still see the need for underlining support
1 like · 50 views
@jarredsumner Could we have a private APIs for structs tc39/proposal-structs
4 likes · 895 views
It's a shame the TC39 signals proposal has seemingly stalled, that's the kind of primitive that would be useful.
tc39/proposal-signals
1 like · 86 views
This has been proposed but never adopted : tc39/proposal-pipeline-operator
Mentions
Temporal API in TypeScript: deadlines and time zones Being able to use the Temporal API in TypeScript was one of my favorite pieces of news around Node.js 26. Over the years I have seen plenty of code where a deadline was off by an hour because somebody wrote “plus 48 hours” instead of “plus two days” and a daylight saving change happened in between. Bugs like that don’t show up in tests. They show up in production, twice a year. In my article on Node.js 26 LTS on Lambda I only touched on Temporal briefly. This time we go deeper: step by step, you build the deadline logic for a fictional city library, store the values cleanly in DynamoDB, deploy everything to Lambda with the Serverless Framework and deliberately test across the DST change. Afterwards you will know which Temporal type is meant for what and how to move away from Date or date-fns. > In short > > Temporal replaces Date with dedicated types for instants, calendar dates and times in a time zone. Node.js 26 ships the API without a flag, TypeScript has included the types since version 6.0, and older Lambda runtimes only need a small polyfill. The biggest pitfall is choosing the wrong type: if you store a deadline as a plain date without a time zone, you only move the problem somewhere else. What you will learn in this article - Which Temporal type is right for instants, calendar days and deadlines - How to set up Temporal in TypeScript, Node.js 26 and on Lambda with nodejs24.x - How to calculate and test deadlines correctly across DST changes - How to store Temporal values in DynamoDB, including TTL - How to migrate step by step from Date or date-fns Why Date so often gets deadlines wrong Date has been part of JavaScript since 1995 and was closely modeled on an early Java class at the time. You can still tell. Months start at 0, so new Date(2026, 9, 25) is October 25. A Date object is mutable, and if you pass it to a function you may get it back changed. On top of that, Date knows exactly two time zones: UTC and the local time zone of the machine running the code. That last point is especially tricky in a Serverless setup. A Lambda function runs in UTC by default. Your customers, however, live in Berlin, Vienna or Zurich. Ask Lambda for “today” at 0:30 am German time and you still get yesterday. Lambda is doing everything right here. The real question is: whose “today” do you mean? The classic example is the DST change. On October 25, 2026, clocks in Germany go back from 3:00 am to 2:00 am, so that day has 25 hours. If you create a deadline of “two days” on October 24 at 10:00 am and calculate it as 48 hours, you end up at 9:00 am on October 26. What you meant was 10:00 am. That is why Temporal clearly separates exact durations in hours from calendar arithmetic in days. const pickup = Temporal.ZonedDateTime.from("2026-10-24T10:00[Europe/Berlin]"); console.log(pickup.add({ hours: 48 }).toString()); // 2026-10-26T09:00:00+01:00[Europe/Berlin] (exactly 48 hours later) console.log(pickup.add({ days: 2 }).toString()); // 2026-10-26T10:00:00+01:00[Europe/Berlin] (two calendar days later) Both results are correct. They simply answer different questions. With Date you have to keep that difference in your head, while Temporal makes you write it down. Which Temporal type for which purpose? Temporal is a namespace like Math, not a constructor. It contains several classes, and all objects are immutable: every operation returns a new object. For everyday work you mainly need these types: | Type | What it describes | Typical example | | --- | --- | --- | | Temporal.Instant | Exact point on the timeline, without calendar or time zone | Event timestamp, log entry | | Temporal.ZonedDateTime | Instant plus time zone, handles summer and winter time | Return deadline “Nov 19 at 6:00 pm in Berlin” | | Temporal.PlainDate | Calendar date without time and without time zone | Birthday, day of borrowing, holiday | | Temporal.PlainTime | Wall-clock time without a date | Closing time “18:00” | | Temporal.PlainDateTime | Date and time without a time zone | Form input before the time zone is known | | Temporal.Duration | Length of time in years, months, days, hours … | Loan period, gap between two appointments | There are also PlainYearMonth (for a billing month, say), PlainMonthDay (recurring anniversaries) and Temporal.Now for the current time in various forms. !Decision tree: need an exact instant? Yes leads to Instant or ZonedDateTime, No to PlainDate or PlainTime Rule of thumb for choosing a Temporal type The rule of thumb for choosing a type For every value I ask myself one single question: do I need to know exactly when this happens in the world? If yes, I need an Instant or a ZonedDateTime. A return deadline belongs in this group, because at 6:01 pm in Berlin it has expired, no matter where the server is. If the answer is no, a plain type is enough. A birthday is on May 3, whatever time zone you happen to be in. A mistake I see often: deadlines get stored as PlainDateTime because it looks so simple. Then exactly the information you later need for comparisons is missing. PlainDateTime is meant for intermediate steps, for example a form before it is clear which time zone the appointment takes place in. Setting up the Temporal API in TypeScript Before you start, a quick look at support. Temporal has reached stage 4 in the TC39 process, the standards committee for JavaScript, which means it is fully specified. Firefox has shipped the API since version 139, Chrome since version 144 and Node.js since version 26. According to the proposal repository, Safari is still missing. TypeScript has included the matching types since version 6.0. Prerequisites - Node.js 26 locally (for native Temporal support) or Node.js 22/24 with a polyfill - TypeScript 6.0 or later; the example was tested with TypeScript 7.0.2 - For deployment: an AWS account and the Serverless Framework v4 (npm i -g serverless) - Basic knowledge of Lambda and DynamoDB helps but isn’t required Lambda runtime and polyfill On Lambda, nodejs26.x is still a public preview, and AWS plans the regular launch for November 2026. Preview runtimes come without the Lambda SLA and are not intended for production use. Until then, use nodejs24.x and load the temporal-polyfill package (MIT license). Its entry point temporal-polyfill/global installs Temporal globally only if the runtime doesn’t already provide it. I checked this in the package’s source code: on Node.js 26 the import simply does nothing. So later you can switch the runtime without touching a single line of code. Configuring TypeScript You enable the types via the lib option. Either take all of esnext or only the esnext.temporal part. I prefer the more granular option because it makes clear which new features the project actually uses. { "compilerOptions": { "target": "es2024", "lib": ["es2024", "esnext.temporal"], "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "types": ["node"], "skipLibCheck": true }, "include": ["src", "test"] } allowImportingTsExtensions and erasableSyntaxOnly let Node.js run the TypeScript files directly. We will use that for the tests in a moment, without any build step. Step by step: loan deadlines for a library Our example is a fictional city library with clear rules. A book may be borrowed for 21 days. If the last day falls on a Sunday or a holiday, the return moves to the next opening day. You can return books until 6:00 pm Berlin time. Two days before the deadline a reminder goes out at 9:00 am, and every overdue day costs 50 cents, up to a maximum of 10 euros. Step 1: Create the project mkdir temporal-library-loans && cd temporal-library-loans npm init -y npm pkg set type=module npm i temporal-polyfill @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm i -D typescript @types/node @types/aws-lambda mkdir src test Then create the tsconfig.json shown above. Next comes a tiny file that loads the polyfill when needed: // src/temporal.ts // Loads the polyfill only if the runtime has no native Temporal // (e.g. nodejs24.x on Lambda). On Node.js 26 this does nothing. import "temporal-polyfill/global"; Step 2: The deadline logic as pure functions The rules go into a file without any AWS dependencies. That makes them easy to test and independent of where the code runs later. // src/loan-rules.ts import "./temporal.ts"; export const LIBRARY_ZONE = "Europe/Berlin"; export const LOAN_DAYS = 21; export const CLOSING_TIME = Temporal.PlainTime.from("18:00"); export const FEE_PER_DAY_CENTS = 50; export const MAX_FEE_CENTS = 1000; // Holidays when the library is closed (fictional selection) export const HOLIDAYS = new Set(["2026-12-25", "2026-12-26", "2027-01-01"]); function isClosed(date: Temporal.PlainDate): boolean { return date.dayOfWeek === 7 || HOLIDAYS.has(date.toString()); } // Due date: day of borrowing + 21 days; on a Sunday or holiday, the next opening day export function dueDate(borrowedOn: Temporal.PlainDate): Temporal.PlainDate { let due = borrowedOn.add({ days: LOAN_DAYS }); while (isClosed(due)) { due = due.add({ days: 1 }); } return due; } // Exact deadline: due date at 6:00 pm Berlin time export function returnDeadline(due: Temporal.PlainDate): Temporal.ZonedDateTime { return due.toZonedDateTime({ timeZone: LIBRARY_ZONE, plainTime: CLOSING_TIME }); } // Reminder two calendar days earlier at 9:00 am – wall-clock time, even across DST changes export function reminderAt(deadline: Temporal.ZonedDateTime): Temporal.ZonedDateTime { return deadline.subtract({ days: 2 }).with({ hour: 9, minute: 0 }); } // Overdue days: calendar days between due date and today (never negative) export function overdueDays(due: Temporal.PlainDate, today: Temporal.PlainDate): number { const days = due.until(today, { largestUnit: "days" }).days; return Math.max(0, days); } export function lateFeeCents(due: Temporal.PlainDate, today: Temporal.PlainDate): number { return Math.min(overdueDays(due, today) * FEE_PER_DAY_CENTS, MAX_FEE_CENTS); } // "Today" from the library's point of view – not from the Lambda server's (UTC) export function libraryToday(now: Temporal.Instant): Temporal.PlainDate { return now.toZonedDateTimeISO(LIBRARY_ZONE).toPlainDate(); } A few spots deserve a second look. The due date is a PlainDate, because “21 days later” is purely a calendar question. dayOfWeek follows the ISO standard from 1 (Monday) to 7 (Sunday), so the Sunday-is-zero confusion of Date is finally gone. Only in returnDeadline do time and time zone come in, turning the calendar day into an actual instant. For the reminder, we deliberately calculate in calendar days and then set the time with with(). If the DST change falls between reminder and deadline, it still stays at 9:00 am wall-clock time. That is exactly what people expect when a reminder is supposed to arrive “at nine in the morning”. The libraryToday function looks harmless but matters a lot. It receives an Instant and converts it into the library’s time zone. As a result, “today” is always Berlin’s today, even though Lambda runs in UTC. And because the instant comes from outside, you can simulate any time you like in tests. Step 3: Storing Temporal values in DynamoDB DynamoDB has no date type. So you have to decide in which form to store the values, and that decision matters more than it seems. My suggestion: - Plain calendar dates as ISO strings like 2026-11-19. They are readable and sort lexicographically. - Instants as UTC strings like 2026-11-19T17:00:00Z. That way comparisons and range queries on a sort key work. - The time zone separately as an IANA name like Europe/Berlin, so you can restore local time for display. - For DynamoDB TTL, an additional number with epoch seconds, because TTL ignores anything that isn’t a Number attribute. // src/storage.ts import "./temporal.ts"; export interface Loan { loanId: string; bookId: string; memberId: string; borrowedOn: Temporal.PlainDate; due: Temporal.PlainDate; deadline: Temporal.ZonedDateTime; } export interface LoanItem { pk: string; bookId: string; memberId: string; borrowedOn: string; // "2026-10-29" – plain date, no time dueDate: string; // "2026-11-19" deadlineAt: string; // "2026-11-19T17:00:00Z" – UTC, sortable timeZone: string; // "Europe/Berlin" – for display expiresAt: number; // epoch seconds for DynamoDB TTL } const KEEP_AFTER_DEADLINE = { days: 180 }; export function toItem(loan: Loan): LoanItem { return { pk: `LOAN#${loan.loanId}`, bookId: loan.bookId, memberId: loan.memberId, borrowedOn: loan.borrowedOn.toString(), dueDate: loan.due.toString(), deadlineAt: loan.deadline.toInstant().toString(), timeZone: loan.deadline.timeZoneId, expiresAt: Math.floor(loan.deadline.add(KEEP_AFTER_DEADLINE).epochMilliseconds / 1000), }; } export function fromItem(item: LoanItem): Loan { return { loanId: item.pk.replace("LOAN#", ""), bookId: item.bookId, memberId: item.memberId, borrowedOn: Temporal.PlainDate.from(item.borrowedOn), due: Temporal.PlainDate.from(item.dueDate), deadline: Temporal.Instant.from(item.deadlineAt).toZonedDateTimeISO(item.timeZone), }; } Why not just store the string of a ZonedDateTime, i.e. 2026-11-19T18:00:00+01:00[Europe/Berlin]? It does contain everything, but it doesn’t sort reliably once values with different offsets end up in the same column. The UTC instant doesn’t have that problem. You can read more about TTL and expiry dates in my post DynamoDB TTL: build seat holds that expire. Step 4: Lambda handler and serverless.yml The handler is intentionally lean. It reads the book and member from the request, calculates date, deadline and reminder and writes the record to DynamoDB. // src/handler.ts import { randomUUID } from "node:crypto"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { dueDate, libraryToday, reminderAt, returnDeadline } from "./loan-rules.ts"; import { toItem, type Loan } from "./storage.ts"; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const TABLE = process.env.LOANS_TABLE ?? ""; export async function createLoan(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> { const body = JSON.parse(event.body ?? "{}") as { bookId?: string; memberId?: string }; if (!body.bookId || !body.memberId) { return { statusCode: 400, body: JSON.stringify({ message: "bookId and memberId are required" }) }; } const borrowedOn = libraryToday(Temporal.Now.instant()); const due = dueDate(borrowedOn); const deadline = returnDeadline(due); const loan: Loan = { loanId: randomUUID(), bookId: body.bookId, memberId: body.memberId, borrowedOn, due, deadline, }; await db.send(new PutCommand({ TableName: TABLE, Item: toItem(loan) })); return { statusCode: 201, body: JSON.stringify({ loanId: loan.loanId, dueDate: due.toString(), deadline: deadline.toString(), reminder: reminderAt(deadline).toString(), }), }; } > 🎬 Flow of a book loan with Temporal – this animated diagram only runs on my site: watch it on sascha-ms.de For the infrastructure I am using the Serverless Framework here. Since version 4 it builds TypeScript itself with esbuild, so no extra build configuration is needed. # serverless.yml service: library-loans frameworkVersion: "4" provider: name: aws runtime: nodejs24.x # switch to nodejs26.x once it is GA region: eu-central-1 architecture: arm64 memorySize: 256 environment: LOANS_TABLE: ${self:service}-${sls:stage}-loans TZ: UTC # Lambda default; library time comes from the code iam: role: statements: - Effect: Allow Action: - dynamodb:PutItem Resource: - !GetAtt LoansTable.Arn functions: createLoan: handler: src/handler.createLoan events: - httpApi: path: /loans method: post resources: Resources: LoansTable: Type: AWS::DynamoDB::Table Properties: TableName: ${self:service}-${sls:stage}-loans BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: pk AttributeType: S KeySchema: - AttributeName: pk KeyType: HASH TimeToLiveSpecification: AttributeName: expiresAt Enabled: true The IAM role may only call PutItem on exactly this table. The handler needs nothing more. Deploy and try it out: serverless deploy curl -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/loans" \ -H "Content-Type: application/json" \ -d '{"bookId":"book-42","memberId":"reader-7"}' If you borrow on October 29, 2026, the response looks roughly like this (the loanId will of course differ): { "loanId": "3f2c9a4e-8b1d-4c57-9e0a-5d6f7a8b9c01", "dueDate": "2026-11-19", "deadline": "2026-11-19T18:00:00+01:00[Europe/Berlin]", "reminder": "2026-11-17T09:00:00+01:00[Europe/Berlin]" } Step 5: Testing across the DST change Now comes the part I consider most important for date logic. Tests with “today plus three days” are almost always green and prove little. They only become meaningful with fixed dates around the DST change, holidays and the day boundary between UTC and Berlin. // test/loan-rules.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { dueDate, lateFeeCents, libraryToday, overdueDays, reminderAt, returnDeadline } from "../src/loan-rules.ts"; const day = (s: string) => Temporal.PlainDate.from(s); test("due date is 21 days after borrowing", () => { assert.equal(dueDate(day("2026-10-29")).toString(), "2026-11-19"); }); test("holidays and Sundays move to the next opening day", () => { // Dec 25 holiday, Dec 26 holiday, Dec 27 Sunday assert.equal(dueDate(day("2026-12-04")).toString(), "2026-12-28"); }); test("deadline stays at 6:00 pm Berlin time, even after the DST change", () => { const deadline = returnDeadline(day("2026-10-26")); assert.equal(deadline.toString(), "2026-10-26T18:00:00+01:00[Europe/Berlin]"); assert.equal(deadline.toInstant().toString(), "2026-10-26T17:00:00Z"); }); test("reminder at 9:00 am wall-clock time across the DST change", () => { const reminder = reminderAt(returnDeadline(day("2026-10-26"))); assert.equal(reminder.toString(), "2026-10-24T09:00:00+02:00[Europe/Berlin]"); }); test("overdue days and capped fee", () => { assert.equal(overdueDays(day("2026-11-19"), day("2026-11-23")), 4); assert.equal(overdueDays(day("2026-11-19"), day("2026-11-10")), 0); assert.equal(lateFeeCents(day("2026-11-19"), day("2026-11-23")), 200); assert.equal(lateFeeCents(day("2026-11-19"), day("2026-12-31")), 1000); }); test("“today” is based on Berlin, not UTC", () => { // 23:30 UTC is already the next day in Berlin assert.equal(libraryToday(Temporal.Instant.from("2026-10-29T23:30:00Z")).toString(), "2026-10-30"); }); You can run the tests directly with Node.js, without a compile step: npx tsc --noEmit node --test test/*.test.ts Expected result: all tests pass. I ran them on Node.js 26.13 with native Temporal and on Node.js 22 with the polyfill, both times with identical results. The repository also contains a second test for the round trip through DynamoDB and the conversion from Date, which I leave out here to save space. Summer and winter time: what Temporal decides behind the scenes Twice a year there are times in Berlin that are ambiguous. In spring an hour is missing: on March 28, 2027 the clock jumps from 2:00 am to 3:00 am, so “2:30 am” doesn’t exist that day. In autumn an hour happens twice: on October 25, 2026, 2:30 am occurs once in summer time and once in winter time. Temporal solves this with the disambiguation option. The default value compatible behaves like Date: inside the gap the time is pushed forward, and in the repeated hour the earlier one wins. With earlier or later you pick a variant on purpose, and with reject Temporal throws a RangeError. // Spring gap: 2:30 am does not exist Temporal.ZonedDateTime.from("2027-03-28T02:30[Europe/Berlin]").toString(); // 2027-03-28T03:30:00+02:00[Europe/Berlin] // Repeated hour in autumn: deliberately pick the second 2:30 am Temporal.PlainDateTime.from("2026-10-25T02:30") .toZonedDateTime("Europe/Berlin", { disambiguation: "later" }) .toString(); // 2026-10-25T02:30:00+01:00[Europe/Berlin] // Often the best choice for booking systems: an error beats a silent shift Temporal.PlainDateTime.from("2027-03-28T02:30") .toZonedDateTime("Europe/Berlin", { disambiguation: "reject" }); // RangeError Something similar applies to month arithmetic. January 31 plus one month gives February 28 with the default overflow: "constrain". If you prefer an error, pass { overflow: "reject" }. For subscriptions and contract terms this is a business decision that you should clarify with the domain experts instead of leaving it to chance. Migrating from Date and date-fns Hardly any project can switch everything overnight. It doesn’t have to. I would use Temporal first in new business logic and convert at the edges, wherever older libraries, ORMs or SDKs still expect or return Date. // src/legacy.ts import "./temporal.ts"; // Existing code still returns Date objects? This converts them without loss. export function fromLegacyDate(value: Date): Temporal.Instant { return Temporal.Instant.fromEpochMilliseconds(value.getTime()); } // For libraries that still expect Date (e.g. older SDKs) export function toLegacyDate(value: Temporal.Instant): Date { return new Date(value.epochMilliseconds); } Going through epoch milliseconds is lossless, because Date only knows milliseconds anyway. Native runtimes and the polyfill also offer Date.prototype.toTemporalInstant(). I still prefer the explicit function, because it shows in the code where the old world ends. With date-fns, Day.js or Luxon it pays to look at what the library is actually used for. Often it is a handful of functions like addDays, differenceInDays or format. Temporal replaces the first two directly with add() and until(). For formatting there is toLocaleString(), which uses Intl.DateTimeFormat internally. Temporal.PlainDate.from("2026-11-19").toLocaleString("en-US", { dateStyle: "full" }), for example, returns “Thursday, November 19, 2026”. Running both side by side is fine for a transition period. The only important thing is that a function works either with Temporal or with Date, not with both. Common mistakes with the Temporal API in TypeScript A few pitfalls kept coming up while I was trying things out. - Comparing with === or <: Two Temporal objects with the same value are still different objects. Use equals() or the static compare() method. Comparing with < deliberately throws a TypeError. - Temporal.Now deep inside business logic: Then you can’t control time in tests. Get the current time at the edge, for example in the handler, and pass it on as a parameter. - JSON there, but not back: JSON.stringify turns Temporal objects into strings. When reading them back, you have to convert them with from() yourself, or you keep calculating with strings. - structuredClone and workers: Temporal objects can’t be structured-cloned, and Node.js throws a DataCloneError. Pass strings and rebuild the objects on the other side. - Storing an offset instead of a time zone: +01:00 is not a time zone. In summer, Berlin uses +02:00. If you only know the offset, you can’t calculate correctly into the future. Cost, performance and security The polyfill has a price, but it is modest. In my example, the minified handler including the polyfill weighs about 60 KB, not counting the AWS SDK, which is part of the runtime. For a Lambda cold start that hardly matters. Once nodejs26.x is generally available, you can remove the polyfill and save those bytes entirely. DynamoDB TTL costs nothing extra. Expired items are deleted without consuming write capacity, though not immediately: according to AWS, typically within a few days. Queries should therefore filter out expired items themselves. With global tables, however, the replicated deletes in other regions do consume write capacity. Two notes on security. Always validate date values from requests with from() inside a try block, because invalid input throws a RangeError. And don’t accept time zones from requests unchecked; compare them against a list of allowed zones. For scheduled reminders, EventBridge Scheduler is a good fit, since it understands cron expressions with a time zone. My EventBridge cron explainer shows how such expressions are built. My take I consider Temporal one of the most useful additions JavaScript has received in recent years. You could build correct code with Date, too. What makes Temporal valuable is that it forces the right questions. If you have to choose between PlainDate and ZonedDateTime, you automatically think about what a value means in business terms. At the same time, I wouldn’t rebuild everything at once. Existing code with date-fns that has been running reliably for years is not an emergency. New business logic, though, I would write with Temporal starting now, on Lambda with the polyfill for the time being. For frontends that also serve Safari, the polyfill remains mandatory for a while anyway. One point where I stay skeptical: the API is large. Teams should agree on a few types and clear storage formats, otherwise you quickly end up with a new kind of sprawl, just with more modern names. FAQ Can I use the Temporal API in production already? Yes. Temporal is fully standardized and available without a flag in Node.js 26, Chrome and Firefox. For Safari and older Node versions such as nodejs24.x on Lambda, use a polyfill like temporal-polyfill. Which TypeScript version do I need for Temporal? The types are built in from TypeScript 6.0. You enable them with "lib": ["esnext"], with "target": "esnext" or specifically with esnext.temporal. What is the difference between Instant and ZonedDateTime? Both describe an exact point in time. An Instant, however, has no time zone and therefore no calendar. ZonedDateTime also knows the time zone in which the instant is viewed, so it can calculate correctly with days, months and DST changes. Does Temporal replace date-fns, Luxon or Day.js? For arithmetic, comparisons and time zones, in many cases yes. For formatting, Temporal uses Intl.DateTimeFormat. Special features like relative times (“3 days ago”) are covered by Intl.RelativeTimeFormat or still by a library. Your next step Find a place in your project where a deadline or appointment is calculated with Date today, and write a test for October 25 or March 28. If it passes, great. If not, you have found your first candidate for Temporal. How do you handle time zones in your Lambda functions so far: everything in UTC with conversion in the frontend, or do you already calculate with local time in the backend? Let me know in the comments. Time zones matter not only for deadlines but also for triggering jobs. I show how to start cron jobs reliably at German local time in EventBridge Scheduler time zone: cron at local time. Sources - TC39: Temporal proposal (stage 4, implementation status) - MDN: Temporal - Node.js: release notes v26.0.0 - TypeScript blog: Announcing TypeScript 6.0 - AWS: Lambda runtimes (nodejs26.x as public preview) - temporal-polyfill on GitHub - AWS: Time to Live in DynamoDB This post was created with AI assistance. --- 👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser. Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
Stop Spreading Iterables Into Arrays to Join Them. Iterator.concat() Is Here TL;DR: Iterator.concat(a, b, c) walks any iterables one after another and returns a lazy iterator. No temporary array, no hand-written generator, and it has been Baseline Newly available since March 2026. The array you build just to throw away You have an array, a Set, and a generator. You want to loop over all of them in order. So you write the line everyone writes: for (const item of [...pinned, ...recentSet, ...loadMore()]) { render(item); } It works. It also copies every item into a brand new array, runs the generator to the very end, and then hands the array to the garbage collector a moment later. If you only needed the first few items, you paid for all of them. The careful version is a generator with yield*: function* concat(...iterables) { for (const iterable of iterables) { yield* iterable; } } Correct, lazy, and copy-pasted into a utils folder near you. What it is Iterator.concat() is that generator, built into the language. It takes any number of iterables and returns an iterator that yields from each one in turn: const array = [1, 2, 3]; const set = new Set([4, 5]); function* gen() { yield 6; yield 7; } console.log([...Iterator.concat(array, set, gen())]); // [1, 2, 3, 4, 5, 6, 7] The result is a real iterator, so the iterator helpers work on it directly: const evensTimesTen = Iterator.concat([1, 2, 3], new Set([4, 5, 6])) .filter((n) => n % 2 === 0) .map((n) => n * 10) .toArray(); console.log(evensTimesTen); // [20, 40, 60] Maps are iterables of [key, value] pairs, which makes "defaults, then overrides" a one-liner: const defaults = new Map([["theme", "light"], ["lang", "en"]]); const user = new Map([["theme", "dark"]]); const settings = new Map(Iterator.concat(defaults, user)); console.log(Object.fromEntries(settings)); // { theme: "dark", lang: "en" } Later keys win, because that is how the Map constructor already behaves. The realistic one: take only what you need Here is where laziness pays. Two sources of 1000 rows each, and a UI that shows the first three: let pulled = 0; function* page(start) { for (let i = start; i < start + 1000; i++) { pulled++; yield i; } } const spread = [...page(0), ...page(1000)].slice(0, 3); console.log(spread, pulled); // [0, 1, 2] 2000 pulled = 0; const lazy = Iterator.concat(page(0), page(1000)).take(3).toArray(); console.log(lazy, pulled); // [0, 1, 2] 3 Same answer. The spread version produced 2000 rows to keep 3. The concat version produced 3. Replace "row" with "parsed log line" or "decoded image" and that gap stops being academic. It is also lazy about opening each source. Logging every [Symbol.iterator]() call on two iterables gave this order: created open a next 1 next 2 open b next 3 Nothing is opened when you call concat, and b is not touched until a runs out. That also means infinite sources are fine, as long as you stop early: function* naturals() { let i = 1; while (true) yield i++; } console.log(Iterator.concat(["start"], naturals()).take(5).toArray()); // ["start", 1, 2, 3, 4] Can I use it? | Where | Status | |---|---| | Baseline | Newly available since March 2026 | | Chrome and Edge | 146 | | Firefox | 147 | | Safari (macOS and iOS) | 26.4 | | Node.js 24 | Not yet: typeof Iterator.concat is "undefined" in v24.11.0 | | Spec | TC39 Iterator Sequencing proposal, Stage 4 | Everything in this post ran in Chrome 156. If you need it in Node today, keep the five-line generator from above as a fallback. Gotchas 1. Strings are rejected, even though they are iterable. Iterator.concat([1], "ab") throws TypeError: Iterator.concat called on non-object right away, at the call, not later during iteration. null throws the same error. Wrap a string first: Iterator.from("bc") or [..."bc"] both worked. 2. Bare iterators need Iterator.from(). An object with only a next() method is not iterable, and passing one threw TypeError: #<Object> is not iterable. Built-in iterators like [1, 2, 3].values() are fine, because they are iterable too. 3. The result is single use. Spreading the same concat result twice gave [1, 2, 3] the first time and [] the second. It is an iterator, not an array, so call Iterator.concat again if you need a second pass. 4. Breaking early closes what it opened. With two generators that log in finally, a for...of that broke on the first item of the second one logged closed a when a ran out and closed b on the break. Your cleanup code runs, which is exactly what you want. Wrap-up Iterator.concat() is the yield* helper you kept rewriting, now with iterator helpers attached. Next time you type [...a, ...b] only to loop over it once, try Iterator.concat(a, b) instead. Sources - Iterator.concat() (MDN) - Iterator Sequencing proposal (TC39) - Finished proposals (TC39) - Iterator browser compatibility data (MDN BCD) This article was written with the help of AI and checked against the sources above.
It would be interesting if browsers supported more languages but I don't mind the monoculture. I have spent a lot of time investing in languages, frameworks and tools, so much of that learning is both useless and worthless today. Most of what I have mastered isn't viable to use for anything in 2026. About ten years ago I deliberately started doubling-down on JavaScript instead, it's good-enough for practically any use case and I've been able to leverage it for web, mobile, desktop, server and games. There are things other languages can do better, but I love not having to install them and manage their different versions and dependencies, and stay familiar with their idiomatic approaches, and keep up-to-date with their evolution. There are outcomes where a monoculture can be bad, but structurally I think JavaScript is in a good place - open standard, open governance, open to proposals, massive open source culture. github.com/tc39/ecma262/blob/main/CONTRIBUTING.md
Why JavaScript Silently Changes Large JSON Numbers --- title: Why JavaScript Silently Changes Large JSON Numbers published: true description: JSON.parse rounds integers above 2^53 without an error. A reproducible example, why it happens, how to detect it, and how to read big IDs exactly. tags: javascript, json, webdev, programming canonical_url: curlyjson.com --- JavaScript can silently change large integers when it parses perfectly valid JSON. That matters for database IDs, API responses, and the tools you use to look at them. Run this in Node or a browser console: const order = JSON.parse('{"orderId": 9007199254740993, "amount": 1.50}'); console.log(order.orderId); console.log(JSON.stringify(order)); Output: 9007199254740992 {"orderId":9007199254740992,"amount":1.5} The ID is now off by one. There's no exception and no warning, and the result is still valid JSON. If you pass that ID to the next request, you're asking about a different order. Where the digit goes JSON's grammar puts no limit on the size of a number. JSON.parse does: it turns every number into a JavaScript Number, which is an IEEE 754 double. A double has a 53-bit significand, so every integer up to 2^53 − 1 is stored exactly: Number.MAX_SAFE_INTEGER // 9007199254740991 Above that, only some integers fit. Between 2^53 and 2^54, a double can only hold even integers. 9007199254740993 is odd, so it's rounded to a neighbour, 9007199254740992. The same thing happens to the literal in your source code, before any JSON is involved: 9007199254740993 === 9007199254740992 // true Further up the range the gaps get wider, so the error gets bigger: JSON.parse('{"big": 12345678901234567890}').big // 12345678901234567000 Not every large integer changes: 9007199254740992 is stored exactly. The trouble is that you can't tell from the parsed value whether it was rounded. Two related changes come from the same step. 1.50 comes back as 1.5: the value is the same, but once it's a Number there's no record of the trailing zero, so JSON.stringify can't reproduce what you sent. And a number too large for a double becomes Infinity, which JSON can't represent, so it's written out as null: JSON.stringify(JSON.parse('{"d": 1e400}')) // '{"d":null}' | JSON input | After JSON.parse + JSON.stringify | What changed | |---|---|---| | 9007199254740993 | 9007199254740992 | Integer rounded | | 12345678901234567890 | 12345678901234567000 | Integer rounded | | 1.50 | 1.5 | Trailing zero dropped, value unchanged | | 1e400 | null | Too large: parsed as Infinity, which JSON can't represent | | "9007199254740993" | "9007199254740993" | Nothing: strings are kept as written | JSON itself doesn't limit number size. RFC 8259 lets implementations set their own limits, and notes that integers between −(2^53 − 1) and 2^53 − 1 are the ones every implementation using double-precision numbers will agree on. The rounding comes from JavaScript's Number, not from JSON. What's usually affected - 64-bit integer IDs (BIGINT) once their values pass 2^53, for example IDs generated from timestamps or random bits rather than a small counter. - Snowflake-style IDs. X (Twitter) and Discord both use them, and both are far above 2^53. - Numbers produced by backends with native 64-bit integers. The server serializes them correctly; the precision is lost only when JavaScript parses them. Some APIs work around it. Discord's API reference says snowflakes are always returned as strings in the HTTP API, to avoid integer overflow in some languages. Twitter's API returned every ID twice, as the number id and the string id_str, and its documentation told JavaScript developers to use id_str. Checking whether a value may have been rounded After parsing, Number.isSafeInteger tells you whether an integer is inside the exact range: Number.isSafeInteger(order.orderId) // false Number.isSafeInteger(9007199254740991) // true false means the value may have been rounded, not that it was: 9007199254740992 is itself representable. To know for sure you need the original text, and newer runtimes give it to you. The reviver function passed to JSON.parse now receives a third argument, context, whose source property is the number exactly as it was written. This comes from the TC39 proposal JSON.parse source text access, which is at Stage 4. This function finds integers outside JavaScript's safe integer range and shows their original text alongside the parsed value: function unsafeIntegers(text) { const found = []; JSON.parse(text, (key, value, context) => { if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) found.push({ key, source: context.source, parsed: value }); return value; }); return found; } unsafeIntegers('{"id": 9007199254740993, "user": {"id": 42}, "ids": [12345678901234567890]}'); Output in Node 23: [ { key: 'id', source: '9007199254740993', parsed: 9007199254740992 }, { key: '0', source: '12345678901234567890', parsed: 12345678901234567000 } ] The second key is '0' because the value is the first element of the ids array. In runtimes that don't support the proposal, context is undefined, so check your target environments before relying on it. Keeping large integers exact Send them as strings. If you control the API, "orderId": "9007199254740993" is the most robust fix, because it works in every client and language. This is what Discord does. Convert them in a reviver. This approach requires a runtime that supports JSON.parse source text access. With it, you can turn a specific field into a BigInt: const text = '{"orderId": 9007199254740993, "amount": 1.50}'; const exact = JSON.parse(text, (key, value, context) => key === 'orderId' ? BigInt(context.source) : value); exact.orderId // 9007199254740993n JSON.stringify refuses to write a BigInt: TypeError: Do not know how to serialize a BigInt The same proposal adds JSON.rawJSON, which lets a replacer emit the digits unchanged: JSON.stringify(exact, (key, value) => typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value); // '{"orderId":9007199254740993,"amount":1.5}' The ID survives the round trip and amount still loses its trailing zero, because the reviver only touched orderId. MDN lists JSON.rawJSON as Baseline 2025, so it's available in current browsers but not in older ones. Use a parser built for it. Libraries such as json-bigint ("JSON.parse with bigints support") and lossless-json ("Parse JSON without risk of losing numeric information") parse numbers without going through Number. Check how each one represents numbers before choosing; their APIs differ. A quick test for JSON tools Formatters, viewers and diff tools often call JSON.parse and JSON.stringify internally. To see whether one does, paste this: { "a": 9007199254740993, "b": 12345678901234567890, "c": 1.50, "d": 1e400 } A tool built on the standard functions gives back: {"a":9007199254740992,"b":12345678901234567000,"c":1.5,"d":null} If the numeric text survives unchanged, that's a good sign the tool preserves number precision for this test. It doesn't guarantee every operation is lossless, so test the transformations you actually use. References - MDN: JSON.parse() - MDN: Number.MAX_SAFE_INTEGER - MDN: JSON.rawJSON() - TC39: JSON.parse source text access --- Written with AI assistance. Every code sample was run in Node 23, and the outputs shown are the actual results.
Discord の ID を JSON.parse したら末尾が 063 から 060 になった。Snowflake は BigInt で読む 背景 自分のサイトに、Discord や X(旧Twitter)の ID から作成日時を取り出すツールを置いています。 作り始めたとき、テスト用の JSON を JSON.parse して ID を取り出したら、末尾の数字が変わっていました。 知識としては知っていたのですが、自分で踏むまでは他人事でした… Snowflake は、Twitter が考えた ID の作り方だ。 64 ビットの整数の上のほうにミリ秒単位の時刻、下のほうにどのサーバーで何番目に作ったかを詰める。 Discord も同じ形で、時刻の起点(エポック)だけが違う。 結論 Snowflake ID は Number で持てません。 - JSON.parse で数値のまま読むと、下位の桁が丸められて別の ID になる - 文字列のまま受け取り、ビット演算は BigInt で行う - Node.js v24 なら、JSON.parse の reviver で元の数字の文字列も取れる JavaScript の Number は小数を含む64ビットの浮動小数点数で、整数として正確に扱えるのは Number.MAX_SAFE_INTEGER(9007199254740991、約 9×10^15)までである。 Snowflake は 18〜19 桁あるので、ここを普通に超えます。 試したこと Discord の開発者ドキュメントに載っている ID の例 175928847299117063 を使いました。 docs.discord.com API から返ってくる JSON を想定して、id を数値、id_str を文字列で持たせた文字列 raw を作り、そのまま JSON.parse しています(Node.js v24.11.1)。 const raw = '{"id":175928847299117063,"id_str":"175928847299117063"}'; console.log(JSON.parse(raw)); 結果は下記でした。 { id: 175928847299117060, id_str: '175928847299117063' } 数値で読んだ id だけ、末尾が 063 から 060 になっています。 エラーも警告も出ないので、比較して初めて気づきます。 日時だけは合ってしまう ややこしいのは、壊れた数値から日時を取り出しても、ほぼ正しく見えることです。 Discord の Snowflake は、右に 22 ビットずらした値がエポック(2015-01-01 00:00:00 UTC、Unixミリ秒で 1420070400000)からのミリ秒だ。 下記は、壊れた Number と、文字列から作った BigInt の両方で日時を出したものです。 4194304 は 2 の 22 乗で、Number には >> で64ビットのシフトができないので割り算で代用しています。 const s = '175928847299117063'; // id_str(文字列) const EPOCH = 1420070400000; // Discord のエポック const viaNumber = Math.floor(Number(s) / 4194304) + EPOCH; const id = BigInt(s); const viaBigInt = Number(id >> 22n) + EPOCH; console.log(new Date(viaNumber).toISOString()); // 2016-04-30T11:18:25.796Z console.log(new Date(viaBigInt).toISOString()); // 2016-04-30T11:18:25.796Z console.log((id >> 17n) & 0x1fn, (id >> 12n) & 0x1fn, id & 0xfffn); // 1n 0n 7n 日時は両方とも 2016-04-30 11:18:25.796 UTC で、ドキュメントの例と同じになりました。 丸められるのは下位の数ビットだけで、時刻は上位に入っているので、たまたま残るという話です。 ただ、最後の行で取り出しているワーカー番号・プロセス番号・連番(ドキュメントでは 1 / 0 / 7)は下位にあるので、Number 経由だと正しく出ません。 それに、ID 自体が別の値になっているので、データベースで探しても見つからないです。 私のツールも、入力を BigInt にしてからシフトしています。 hashitosystem.com JSON から ID を正しく取り出す いちばん楽なのは、API が文字列でも返している項目(X の id_str など)を使うことです。 Discord の API は最初から ID を文字列で返すので、こちらは数値になる心配がありません。 自分で受け取る JSON が数値しか持っていないときは、JSON.parse の reviver(読み込み中に値を差し替える第2引数の関数)が使えます。 Node.js v24 では、reviver の第3引数 context.source に、数値に変換する前の元の文字列が入っていました。 const raw = '{"id":175928847299117063,"id_str":"175928847299117063"}'; const obj = JSON.parse(raw, (key, value, context) => key === 'id' && context && context.source ? BigInt(context.source) : value ); console.log(obj); // { id: 175928847299117063n, id_str: '175928847299117063' } key は今読んでいる項目名で、id のときだけ元の文字列から BigInt を作っています。 context が無い古い環境では value をそのまま返すので、少なくとも落ちはしません(その場合は丸められた値になります)。 この第3引数は TC39 の提案(JSON.parse source text access)で入ったもので、古いブラウザでは使えないかもしれません。 tc39/proposal-json-parse-with-source BigInt はそのままだと JSON.stringify で例外になるので、保存や送信のときは String(id) で文字列に戻しておくのが無難です。 Snowflake ID は数字に見えても数値として扱わない。 受け取りは文字列、計算は BigInt、と分けておけば、桁が化ける事故は起きません。 --- > 本記事はAI補助で執筆した、個人開発の紹介記事です。
JSON.parse Source Text Access: Stop Losing Precision on Big IDs Your API returns an order ID of 9007199254740993. Your frontend logs 9007199254740992. Nobody touched it. JSON.parse quietly rounded it, because every JSON number becomes a JavaScript double, and doubles only hold integers exactly up to Number.MAX_SAFE_INTEGER (9007199254740991). Anything bigger gets snapped to the nearest representable value before your code ever sees it. The usual workarounds are all a little sad. Ask the backend to send IDs as strings (id_str, if you remember the old Twitter API). Pull in a third-party parser like json-bigint. Or run a regex over the raw response to wrap long numbers in quotes before parsing, and hope nothing in a string field looks like a number. The platform now has a real fix: the JSON.parse source text access proposal, which adds a context argument to revivers and a matching JSON.rawJSON() for serialization. It reached Stage 4 at TC39 in late 2025, and it is already Baseline: Chrome 114, Firefox 135, Safari 18.4, Node 21, Deno 1.33, and Bun 1.1.43 all ship it. The reviver gets a third argument A reviver has always received (key, value). It now receives (key, value, context), and for primitive values context.source is the exact slice of JSON text that produced value. The number was still parsed into a lossy double, but you get the original digits next to it, so you can decide what it should become. const json = '{"orderId": 9007199254740993, "total": 42.5}'; const order = JSON.parse(json, (key, value, context) => { if (key === "orderId") { // value is already rounded; context.source is not return BigInt(context.source); } return value; }); order.orderId; // 9007199254740993n order.total; // 42.5 Keying off property names works when you control the schema, but a generic version is more useful in a shared fetch helper. Promote only integers that actually lost precision, and leave everything else alone: const INTEGER = /^-?\d+$/; export function preciseReviver(key, value, context) { if ( typeof value === "number" && !Number.isSafeInteger(value) && context?.source && INTEGER.test(context.source) ) { return BigInt(context.source); } return value; } const res = await fetch("/api/orders"); const data = JSON.parse(await res.text(), preciseReviver); Two details there matter. Response.json() doesn't accept a reviver, so you read the body as text and parse it yourself. And the optional chaining on context?.source is deliberate: context.source only exists for primitives. When the reviver is called for an object or array, source is absent, because there's no single meaningful slice of text to give you once the children have already been revived. Writing it back out with JSON.rawJSON Parsing is half the problem. Hand that BigInt to JSON.stringify and you get TypeError: Do not know how to serialize a BigInt. Convert it to a Number first and you're back to rounding. JSON.rawJSON() closes the loop: it wraps a piece of JSON text that stringify will emit verbatim. function preciseReplacer(key, value) { return typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value; } JSON.stringify({ orderId: 9007199254740993n }, preciseReplacer); // '{"orderId":9007199254740993}' The output is a real JSON number, not a quoted string, so a Go or Java backend expecting an int64 reads it without any schema change. JSON.rawJSON is strict about what it accepts: the text must be valid JSON for a primitive, so JSON.rawJSON('[1, 2]') or a string containing a raw line break throws a SyntaxError. The returned object is frozen with a null prototype, and JSON.isRawJSON() tells you if you're holding one. Decimals, not just big integers Money is the other place this pays off. 0.1 parsed as a double is not exactly one tenth, and a payments API that returns "amount": 19.99 hands you a value that's already slightly wrong before your decimal library sees it. With context.source you can feed the original digits straight into whatever arbitrary-precision type you use: import Decimal from "decimal.js"; const invoice = JSON.parse(body, (key, value, context) => key === "amount" ? new Decimal(context.source) : value, ); On the way out, JSON.rawJSON(amount.toFixed(2)) writes 19.99 as an unquoted number again. When not to reach for it A reviver runs once per value in the document, so it is measurably slower than a plain JSON.parse on large payloads. If your IDs are already strings, or every number fits in the safe range, you don't need any of this. In TypeScript, older lib definitions may not know about the third reviver argument or JSON.rawJSON yet, so you might need a small ambient declaration until your lib setting catches up. For older browsers, core-js ships a polyfill for both halves. The bigger change is where the fix lives. Precision used to be something you negotiated with the backend team or patched with a dependency. Now it's a few lines in the one fetch helper every request already goes through. MDN's pages on JSON.parse() and JSON.rawJSON() have the full reference, and the TC39 proposal covers the design reasoning.
I want bun to have struct support. So here a draft into Webkit. With bun looking to the future and the need the support for high preforming JavaScript. Maybe if Bun adopts then this could help push the spec forward WebKit#904 Stage 2 Proposal tc39/proposal-structs I hope @jarredsumner still see the need for underlining support
@jarredsumner Could we have a private APIs for structs tc39/proposal-structs
It's a shame the TC39 signals proposal has seemingly stalled, that's the kind of primitive that would be useful. tc39/proposal-signals
This has been proposed but never adopted : tc39/proposal-pipeline-operator