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. 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 { 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://.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
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to