Arithmetic with Temporal
Computing with dates and durations across the Temporal API's five types.
A product manager files one ticket. The billing page should tell the customer three things: your trial ends in 12 days, your next invoice is May 31, and you posted your first comment 3 hours ago.
Look closer, and each line is a different kind of arithmetic. “Trial ends in 12 days” counts forward in calendar days from a sign-up date. “Next invoice May 31” adds a month to a billing anchor, but a month is not thirty days, and not every month has a 31st. “3 hours ago” measures backward from now to a fixed point in real time. Three sentences, three kinds of arithmetic on three kinds of value.
Earlier lessons settled where time is stored and zoned.
This one covers what you compute with it, using Temporal from the runtime, and which type each computation belongs on.
The five types, and what arithmetic means on each
Section titled “The five types, and what arithmetic means on each”Reasoning about time starts with one move: before you write an operation, decide which kind of value you’re holding. The type fixes what every operation means.
Temporal covers a web app’s time work with five types, here as a decision table.
| Type | What it models | What add({ days: 1 }) means on it |
|---|---|---|
Temporal.Instant | A fixed point in real time: UTC, no wall clock, never ambiguous. The timestamptz column, the wire. | Exactly 86,400 seconds later. |
Temporal.ZonedDateTime | An Instant plus an IANA zone. The only DST-aware type; the one safe for “9 AM in New York” math. | The same wall-clock time tomorrow: 23, 24, or 25 real hours, depending on DST. |
Temporal.PlainDate | A calendar date: year, month, day, no time, no zone. The date column’s domain type. | Tomorrow. No hours involved at all. |
Temporal.PlainDateTime | A wall-clock date and time with no zone attached. Rarely needed in 2026 SaaS. | — |
Temporal.Duration | An explicit, immutable span (years down to nanoseconds). The input to every arithmetic operation, and a value you can store. | — |
The last column is the point: one method, add({ days: 1 }), means three different real-world operations.
On a ZonedDateTime crossing spring-forward it is 23 real hours, because the clock skipped an hour that night; on an Instant, always 86,400 seconds, with no calendar to bend around; on a PlainDate, just “tomorrow.”
The method didn’t change; the type did.
So before every computation, ask which of the five this value is. Get it right and the operation is obvious; get it wrong and Temporal usually stops you.
Each value below is something a SaaS stores or computes. Drop it into the Temporal type that models it. Drag each item into the bucket it belongs to, then press Check.
invoice.createdAtinvoice.dueDateuser.birthDatesubscription.startedAtThe chips sort by three questions.
“What exact second?” is an Instant; “which calendar day, wherever you stand?” is a PlainDate; “how long?” is a Duration.
The type you store is the type you compute on.
Getting “now”, always with an explicit zone
Section titled “Getting “now”, always with an explicit zone”Anything that starts from the present needs a value for now: a trial that ends 30 days from now, a post from 3 hours ago, an invoice due so many days from today.
Temporal gives you three, under Temporal.Now:
Temporal.Now.instant(); // current Instant (UTC, no zone)Temporal.Now.zonedDateTimeISO(timeZone); // current ZonedDateTime in a named zoneTemporal.Now.plainDateISO(timeZone); // today's calendar date in that zoneinstant() is safe with no argument: an Instant is a raw point in real time, with no zone to get wrong.
The other two take a timeZone, and the rule to memorize is to always pass it.
The API makes that argument optional, a trap inherited from Date.
Call Temporal.Now.plainDateISO() with no argument and it resolves to the runtime’s zone.
On your laptop that’s your local zone, so it looks fine; on Vercel it’s UTC, for every user in every country.
This is the silent bug from Profile timezone: the server’s clock ships UTC to everyone.
The correct form pulls the zone off the session, where the user’s IANA timezone lives as a profile column:
const { user } = await requireOrgUser();const today = Temporal.Now.plainDateISO(user.timeZone);“Today” is zone-relative: it has no answer until you say where.
The server clock reads 02:00 UTC on 2026-03-15. Predict what prints. Predict what this program prints, then press Check.
// Server wall clock is 02:00 UTC on 2026-03-15.const ny = Temporal.Now.plainDateISO('America/New_York');const tokyo = Temporal.Now.plainDateISO('Asia/Tokyo');
console.log(ny.toString());console.log(tokyo.toString());Every operation below assumes you fed “now” your user.timeZone.
Shifting dates: add, subtract, and month-end clamping
Section titled “Shifting dates: add, subtract, and month-end clamping”Every Temporal type for a moment or a date exposes add(duration) and subtract(duration).
Both are immutable: they return a new instance and leave the original untouched, which retires the whole family of Date bugs where one stray .setMonth() mutated a value other code still pointed at.
The argument is never a bare number; it’s a named-component object, where the component name is the unit: { days: 30 }, { months: 1 }, { weeks: 2, days: 3 }, { hours: 24 }.
Call add(30) and Temporal throws on purpose, because “30 of what?” is the ambiguity that lets you ship milliseconds where the function wanted days.
Here are the three call sites you’ll write most often, against this chapter’s domain:
// Trial ends 30 days from today, in the user's zone.const trialEnd = Temporal.Now.plainDateISO(user.timeZone).add({ days: 30 });
// Next billing is one month after the billing anchor — a calendar day.const nextBilling = subscription.anchorDate.add({ months: 1 });
// The floor of a "last 7 days" window.const windowStart = Temporal.Now.instant().subtract({ days: 7 });Notice the type discipline: the right type was chosen before the arithmetic.
trialEnd and nextBilling are calendar work, so they live on PlainDate; the activity-feed window is a real-time cutoff, so it lives on Instant.
And subscription.anchorDate, the calendar day billing recurs on, is a deliberately different field from subscription.startedAt, the exact moment the subscription began.
That one is an Instant, and add({ months: 1 }) on it would throw, since months are meaningless on a bare Instant.
Now the subtlety: month-end clamping. What is January 31st plus one month?
Temporal.PlainDate.from('2026-01-31').add({ months: 1 });// → 2026-02-28February has no 31st, so Temporal clamps to the last valid day of the target month.
This is the default, overflow: 'constrain', and it’s calendar-aware: in a leap year it would clamp to February 29th.
But the clamp has a sharp edge.
`jan31` is the PlainDate 2026-01-31. Predict what prints. Predict what this program prints, then press Check.
const jan31 = Temporal.PlainDate.from('2026-01-31');
const onceThenOnce = jan31.add({ months: 1 }).add({ months: 1 });const twoAtOnce = jan31.add({ months: 2 });
console.log(onceThenOnce.toString());console.log(twoAtOnce.toString());add({ months: 1 }) twice is not add({ months: 2 }). When the month boundary matters, prefer one larger call.Sometimes a silent clamp is itself the wrong answer.
If “January 31st + 1 month” is a billing anchor that must be a real, exact date, you’d rather the impossible date fail loudly than quietly become February 28th.
That’s what overflow: 'reject' is for:
subscription.anchorDate.add({ months: 1 }, { overflow: 'reject' });// → throws RangeError when the target day doesn't existMeasuring a gap: since, until, and largestUnit
Section titled “Measuring a gap: since, until, and largestUnit”The mirror of add/subtract is measuring the distance between two points, the job of since and until.
Both return a Duration.
a.since(b) is “how far is a after b,” positive when a is later.
a.until(b) is the same magnitude with the opposite sign: “how far from a forward to b.”
Pick whichever reads naturally at the call site:
// How long since the user signed up.const accountAge = Temporal.Now.instant().since(user.createdAt);
// How many days until this invoice is due.const lead = Temporal.Now.plainDateISO(user.timeZone).until(invoice.dueDate);
// How long a billing period ran.const periodLength = period.endDate.since(period.startDate);By default a Duration comes back in the largest unit Temporal can compute unambiguously.
For a specific shape, such as days, hours, or seconds, ask with largestUnit:
Temporal.Now.instant().since(user.createdAt, { largestUnit: 'hour' });// → e.g. a Duration of 53 hours, 12 minutes, …Asking for months between two Instants is the exception:
Temporal.Now.instant().since(user.createdAt, { largestUnit: 'month' });// → throws RangeErrorA month has no fixed length, so without a calendar to anchor against, “how many months” has no answer, and Temporal throws rather than guess.
This lesson computes the Duration. Turning it into the words “3 hours ago” is a separate, locale-aware concern covered in the next chapter on internationalization.
Comparing and sorting dates
Section titled “Comparing and sorting dates”Sorting invoices by due date or checking whether one date falls before another is routine work, and Temporal names these operations plainly.
For sorting, every type has a static compare that returns -1, 0, or 1, the exact contract Array.prototype.sort wants:
const byDueDate = [...invoices].sort((a, b) => Temporal.PlainDate.compare(a.dueDate, b.dueDate),);Temporal.Instant.compare and Temporal.ZonedDateTime.compare work the same way.
For yes/no questions, reach for the instance booleans a.equals(b), a.before(b), and a.after(b).
Two earlier rules apply.
From Storage, domain, edge: compare instants with .equals() (or their .epochMilliseconds), never by stringifying them, since Postgres keeps microseconds while your code keeps milliseconds and string equality then fails on a difference that doesn’t matter.
And you can’t compare across types: passing Temporal.PlainDate.compare a ZonedDateTime is a type error, the same locked-set discipline that keeps the storage types separate.
Replacing one field: with
Section titled “Replacing one field: with”Sometimes you don’t want to shift a date by a span; you want to replace one component and leave the rest alone, like “the first of this month” or “exactly 9 AM.”
That’s with(fields): it returns a new instance with the named fields swapped and everything else untouched.
// First of this month.const monthStart = date.with({ day: 1 });
// First of NEXT month — compose with + add.const nextMonthStart = date.with({ day: 1 }).add({ months: 1 });
// 9 AM that day, in that zone.const nineAm = zonedDateTime.with({ hour: 9, minute: 0, second: 0 });That last form is the front half of a pattern from DST & recurring jobs: build a wall-clock time in the user’s zone with with, then call .toInstant() to pin it to the timeline as a storable point.
For the start of a calendar period, use with({ day: 1 }), not round (covered next).
Rounding snaps to a grid, so on “first of the month” it lands on something that looks almost right and isn’t.
Snapping to a grid: round
Section titled “Snapping to a grid: round”Where with edits one field, round snaps a whole value to a grid: the nearest hour, the nearest 15 minutes, and so on.
Two SaaS uses cover most cases.
The first is analytics bucketing. To chart events per hour, floor each timestamp to the hour so they collapse onto a clean X-axis:
const bucket = event.timestamp.round({ smallestUnit: 'hour', roundingMode: 'floor',});The second is snapping user input. A time picker limited to quarter-hour slots rounds the user’s choice to the nearest 15 minutes:
const slot = picked.round({ smallestUnit: 'minute', roundingIncrement: 15, roundingMode: 'halfExpand',});Three knobs do the work: smallestUnit (the grid resolution), roundingIncrement (snap to multiples, such as every 15 minutes), and roundingMode .
Use floor for bucketing and halfExpand for “nearest”; those cover the realistic cases.
Converting between Temporal types
Section titled “Converting between Temporal types”You hold an Instant from the database and need the calendar day it fell on for this user.
Moving between the five types is where people slip: they memorize one method, then apply it in the wrong direction.
Treat conversions as explicit reasoning steps, never coercion.
No + secretly turns one type into another, and there’s no truthy fallback: you name the conversion, and at every zone boundary you name the zone.
Here are the six you’ll reach for:
// Instant ↔ ZonedDateTimeinstant.toZonedDateTimeISO(timeZone);zonedDateTime.toInstant();
// ZonedDateTime ↔ PlainDatezonedDateTime.toPlainDate();plainDate.toZonedDateTime({ timeZone, plainTime: '09:00' });
// String ↔ type (PlainDate and Instant both round-trip through ISO 8601)Temporal.PlainDate.from('2026-05-15');plainDate.toString();Temporal.Instant.from('2026-05-15T13:00:00Z');instant.toString();A conversion chain reads like a sentence.
Take the most common one in this domain, an Instant from the DB into “what day was that for the user,” and read it left to right.
const dayForUser = invoice.createdAt .toZonedDateTimeISO(user.timeZone) .toPlainDate();An Instant straight off the timestamptz column: a precise UTC moment with no day yet. “Which day” is meaningless until you say where.
const dayForUser = invoice.createdAt .toZonedDateTimeISO(user.timeZone) .toPlainDate();Attach the user’s zone. The same moment now has a wall clock, so you know what the user’s clock read. This boundary is where “where” gets decided.
const dayForUser = invoice.createdAt .toZonedDateTimeISO(user.timeZone) .toPlainDate();Drop the clock, keep the date: the calendar day the user would read off the page.
For any other conversion the question is the same: I’m at this type, I need that type, which edge gets me there?
Durations as data
Section titled “Durations as data”A Duration isn’t only the argument you feed add.
It’s a value in its own right that you can build up, store, and parse back, and it’s itself a valid argument to add, so durations compose:
const grace = Temporal.Duration.from({ days: 7 }).add({ hours: 12 });
// A Duration is itself a valid argument to add().const graceEnds = invoice.dueDate.add(grace);That makes configurable spans possible. A trial length differs per plan, 14 days on one tier and 30 on another, so it’s data, not a literal baked into code. Store it as an ISO 8601 duration string in a column, parse it on read, and apply it:
// plan.trialLength is a string column, e.g. 'P30D'.const trialLength = Temporal.Duration.from(plan.trialLength);const trialEnd = signupDate.add(trialLength);This is the wire rule from Storage, domain, edge: 'P30D' crosses the wire and sits in the database, while Temporal.Duration lives only in your code.
One thing to watch shares the months-throw root cause from since: a duration that mixes calendar and exact units, like { months: 1, days: 5 }, is only well-defined against a calendar.
It applies fine to a PlainDate or ZonedDateTime, which carry one, but there’s no sensible way to apply months to a bare Instant.
Six legacy Date habits Temporal retires
Section titled “Six legacy Date habits Temporal retires”Each technique above retires a habit from the 2010s, likely in your muscle memory if you came from another ecosystem. Each is more than old: it’s a bug class Temporal designs out. You can’t zero-index a month you never pass positionally, mutate a value that’s immutable, or get DST wrong on a type that doesn’t model the zone.
new Date(2026, 0, 15); // month is zero-indexed — 0 is Januarydate.setMonth(date.getMonth() + 1); // mutates in place, and zero-indexedDate.now() + 30 * 24 * 60 * 60 * 1000; // "30 days" — wrong across a DST boundarynew Date('2026-05-15'); // a calendar date, rebound to midnight UTCimport { addMonths } from 'date-fns'; // a dependency you no longer pay fora.toISOString() === b.toISOString(); // string comparison — brittle on format driftSix distinct bug classes, one per line.
Temporal.PlainDate.from({ year: 2026, month: 1, day: 15 }); // 1-based, as writtenplainDate.add({ months: 1 }); // immutable, 1-basedTemporal.Now.zonedDateTimeISO(user.timeZone).add({ days: 30 }); // calendar days, DST-correctTemporal.PlainDate.from('2026-05-15'); // a real calendar date, no zone// nothing to import — Temporal is in the runtimeTemporal.PlainDate.compare(a, b) === 0; // structural, preciseSame six jobs, each bug designed out.
Date libraries like date-fns, dayjs, moment, and luxon aren’t wrong, just no longer worth their cost: Temporal is the platform default, so reaching for one in 2026 pays bundle size and maintenance for a problem the runtime already solved.
Try it on a real call site. The function below stamps a trial onto a new account; fill each blank with the correct shape, not the legacy decoy.
Pick the Temporal-correct shape at each blank. The other option in each is a habit we just retired. Pick the right option from each dropdown, then press Check.
function startTrial(user: User, renewsOn: Temporal.PlainDate) { const today = Temporal.Now.plainDateISO(___); const trialEnds = today.add(___); const renewsOnTrialEnd = trialEnds.___; return { trialEnds: trialEnds.toString(), renewsOnTrialEnd };}Polyfilling Temporal on Node 24
Section titled “Polyfilling Temporal on Node 24”Native Temporal landed unflagged in Node 26, the course’s deploy target. Most production SaaS still runs Node 24 LTS, which won’t have it until Node 26 promotes to LTS in October 2026. Until then, you bridge the gap with a polyfill.
Install one: temporal-polyfill (FullCalendar’s, the lean default at roughly 20 KB) for a new project, or @js-temporal/polyfill (the TC39 champions’, larger but full-spec).
Then route it through one seam, lib/temporal.ts, the single import path you’ve sent every Temporal value through all chapter long:
import { Temporal as polyfillTemporal } from 'temporal-polyfill';
export const Temporal = globalThis.Temporal ?? polyfillTemporal;Every file imports Temporal from here, never from the polyfill package directly, and the no-restricted-imports ESLint rule from earlier enforces that path mechanically.
The day you move to Node 26, you delete one line here and every consumer keeps working.
Chrome, Firefox, and Edge ship Temporal natively; Safari still lags. That gap doesn’t reach you: these projects do their Temporal arithmetic on the server and send plain ISO 8601 strings to the client, so the polyfill only matters if you do date math in the browser, which they don’t.
Practice: write the billing arithmetic
Section titled “Practice: write the billing arithmetic”These three functions are the exact shapes from the billing-page ticket that opened the lesson. Fill the four blanks with the shape that matches each comment; the tests check your work, including the month-end edge.
Each blank is the arithmetic core of one billing-page computation. Pick the shape that matches the job named in the comment. Pick the right option from each dropdown, then press Check.
// Trial ends 30 calendar days after signup.function trialEndsOn(signupDate: string): Temporal.PlainDate { return Temporal.PlainDate.from(signupDate).___;}
// Whole calendar days from "today" to the due date.function daysUntilDue(today: string, dueDate: string): number { const now = Temporal.PlainDate.from(today); const due = Temporal.PlainDate.from(dueDate); return now.___.___;}
// One month after the start date — the default clamp is what we want.function nextBillingDate(startDate: string): Temporal.PlainDate { return Temporal.PlainDate.from(startDate).___;}Now run nextBillingDate in your head for a subscription that anchors on the last day of January.
`nextBillingDate` is the function you just completed: `Temporal.PlainDate.from(startDate).add({ months: 1 })`. Predict what prints. Predict what this program prints, then press Check.
console.log(nextBillingDate('2026-01-31').toString());console.log(nextBillingDate('2026-05-15').toString());overflow: 'constrain' clamps Jan 31 to 2026-02-28. May 15 needs no clamp: June has a 15th, so it lands on 2026-06-15.External resources
Section titled “External resources”For the corners this lesson didn’t reach, such as the non-ISO calendars Temporal supports, these are the canonical sources.
The full operation surface for every type — the page to keep open while you write Temporal.
Worked recipes for common real-world computations, straight from the proposal authors.
An interactive sandbox — run curated Temporal snippets live and compare each one against the old Date.
The lean ~20 KB polyfill from the lesson's seam — install it to ship Temporal on Node 24 today.