Skip to content
Chapter 83Lesson 5

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.

TypeWhat it modelsWhat add({ days: 1 }) means on it
Temporal.InstantA fixed point in real time: UTC, no wall clock, never ambiguous. The timestamptz column, the wire.Exactly 86,400 seconds later.
Temporal.ZonedDateTimeAn 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.PlainDateA calendar date: year, month, day, no time, no zone. The date column’s domain type.Tomorrow. No hours involved at all.
Temporal.PlainDateTimeA wall-clock date and time with no zone attached. Rarely needed in 2026 SaaS.
Temporal.DurationAn explicit, immutable span (years down to nanoseconds). The input to every arithmetic operation, and a value you can store.
The five types. Pick the row first, and the operations follow.

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.

Instant A point in real time
PlainDate A calendar day
Duration A span of time
invoice.createdAt
invoice.dueDate
user.birthDate
subscription.startedAt
the moment the 9 AM report actually fired
a plan’s 30-day trial length
the timestamp on a comment
today, from the user’s perspective

The 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 zone
Temporal.Now.plainDateISO(timeZone); // today's calendar date in that zone

instant() 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-28

February 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());

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 exist

Measuring 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 RangeError

A 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.

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.

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.

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.

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 ↔ ZonedDateTime
instant.toZonedDateTimeISO(timeZone);
zonedDateTime.toInstant();
// ZonedDateTime ↔ PlainDate
zonedDateTime.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.

1 / 1

For any other conversion the question is the same: I’m at this type, I need that type, which edge gets me there?

Instant UTC point
ZonedDateTime + zone
PlainDate calendar day
string ISO 8601 · the wire
The conversion map. Every edge is an explicit method — there is no implicit coercion. The blue edges cross a timezone boundary, so they require a named zone (⚑); that's the one place "where" enters the picture.

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.

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 January
date.setMonth(date.getMonth() + 1); // mutates in place, and zero-indexed
Date.now() + 30 * 24 * 60 * 60 * 1000; // "30 days" — wrong across a DST boundary
new Date('2026-05-15'); // a calendar date, rebound to midnight UTC
import { addMonths } from 'date-fns'; // a dependency you no longer pay for
a.toISOString() === b.toISOString(); // string comparison — brittle on format drift

Six distinct bug classes, one per line.

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 };
}

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:

lib/temporal.ts
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.

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());

For the corners this lesson didn’t reach, such as the non-ISO calendars Temporal supports, these are the canonical sources.