Skip to content
Chapter 83Lesson 5

Arithmetic with Temporal

Computing with the Temporal API, shifting, measuring, comparing, and converting dates and durations across its five types.

A product manager files one ticket. The billing page should show 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. Three lines of copy, and you’d guess one afternoon of work.

Then you look closer. “Trial ends in 12 days” counts forward from a sign-up date in calendar days. “Next invoice May 31” adds a month to a billing anchor, but a month is not thirty days, and the 31st is not a day every month has. “3 hours ago” measures backward from right now to a fixed point in real time. One screen, three sentences, and every sentence is a different kind of arithmetic on a different kind of value.

In the 2010s you reached for a date library (date-fns, dayjs, moment) and a fair amount of hope, because the language’s own Date was full of traps: months counted from zero, every method mutated the object in place, and “what day is it” silently meant “what day is it in the server’s timezone.” In 2026 the answer already sits in the runtime. The last four lessons set up where time is stored and zoned; this lesson covers what you compute with it once you’re holding it.

By the end you’ll write any of those three sentences, and the dozen variants a SaaS throws at you every week, without having to stop and think. You’ll also know exactly which Temporal type each computation belongs on. The lesson closes with something concrete: six legacy habits retired for good, and the one-line seam that lets all of this run on the Node your project actually deploys on today.

The five types, and why the type is the decision

Section titled “The five types, and why the type is the decision”

Reasoning about time, rather than memorizing date-library method names, starts with one move: before you write a single operation, decide which kind of value you’re holding. The operation can’t pick the type for you. You pick the type, and the meaning of every operation follows from it.

Temporal does all of a SaaS application’s work with five types. You’ve met them across the chapter; here they are as a decision table rather than a feature list.

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.

Look at that last column. The same method name, add({ days: 1 }), means three genuinely different real-world operations across those three rows. On a ZonedDateTime that straddles spring-forward, “one day later” is 23 real hours, because the clock skipped an hour that night. On an Instant it is always exactly 86,400 seconds, because an Instant has no calendar to bend around. On a PlainDate it is just “tomorrow,” with no notion of hours at all. The method didn’t change; the type did, and the type carries the meaning.

You’ll almost never reach for that PlainDateTime row, a date and time with no zone, in this kind of app. The one realistic use is “this event recurs at 9 AM in whatever zone the viewer happens to be in,” where you deliberately want a wall-clock time unanchored from any single zone until you attach one. It’s listed so you’re not surprised it exists; this lesson won’t cover its surface.

So the question to ask before every computation in this lesson is: which of these five is this value, and what does arithmetic mean on it? Get the type right and the operation is obvious. Get it wrong and Temporal will usually stop you, but reasoning from the type means you rarely get there.

Try that decision on a set of real values before going further.

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

Read the chips back as three questions. Anything that answers “what exact second?” is an Instant: createdAt, startedAt, the comment’s timestamp, and the moment a job fired are all fixed points on the timeline. Anything that answers “which calendar day?” regardless of where you’re standing is a PlainDate: a due date, a birth date, “today” for this user. And anything that answers “how long?” is a Duration, since a 30-day trial is a span, not a moment. This is the same storage decision the lessons on storing instants and calendar days drilled, now reframed as the arithmetic decision, because the type you store is the type you compute on.

Getting “now” without an ambient timezone

Section titled “Getting “now” without an ambient timezone”

Every computation that starts from the present needs a value for now: “trial ends 30 days from now,” “how long ago,” “due in how many days from today.” Temporal hands 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

The first one is safe with no argument, because an Instant has no zone to get wrong: it’s a raw point in real time. The other two take a timeZone, and the rule to commit to memory is to always pass it, explicitly, every time.

The API makes that argument optional, and that optionality is a trap inherited straight from Date. Call Temporal.Now.plainDateISO() with no argument and it resolves to the runtime’s zone. On your laptop that’s whatever you’ve set locally, so it looks fine. In production on Vercel, the runtime’s zone is UTC, for every user in every country. This is the same silent bug you met in Timezone on the profile: a no-arg Intl.DateTimeFormat() reads the server’s clock, not the user’s, and ships UTC to everyone. Temporal.Now repeats that mistake in a new place.

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

Why does this matter so much for “today” specifically? Because “today” is a zone-relative question: it has no answer until you say where. Here is what the no-arg version actually costs you near the date line.

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

Supplying the zone every time, never inferring it, is the precondition for everything that follows. Every operation below that starts from “now” assumes you fed it user.timeZone.

Shifting a date: add, subtract, and the month-end clamp

Section titled “Shifting a date: add, subtract, and the month-end clamp”

This is the workhorse. Every Temporal type that represents a moment or a date exposes two methods: add(duration) and subtract(duration). Both are immutable: they return a new instance and leave the original untouched. That immutability matters because it retires a whole family of Date bugs where one stray .setMonth() mutated a value three call-frames away and corrupted everything still pointing at it.

The argument is never a bare number. It’s a named-component object: { days: 30 }, { months: 1 }, { weeks: 2, days: 3 }, { hours: 24 }. The component name is the unit. Call add(30) and Temporal throws, on purpose, because “30 of what?” is precisely the question that made date-fns call sites unreadable. You can never again ship the classic bug of passing 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 });

Read those and notice the type discipline carrying through. 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. The right type was chosen before the arithmetic. Note too that subscription.anchorDate, the calendar day billing recurs on, is a deliberately different field from the subscription.startedAt you bucketed earlier. That one is an Instant, the exact moment the subscription began, and calling add({ months: 1 }) on it would throw, since months are meaningless on a bare Instant.

Now the subtlety that catches everyone: 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 behavior, named overflow: 'constrain'. It’s calendar-aware, so in a leap year it would clamp January 31st to February 29th instead. So far so reasonable, but the clamp has a sharp edge that will catch you out if you don’t see it coming.

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

That asymmetry, where add({ months: 1 }) twice is not add({ months: 2 }), is the kind of thing intuition gets wrong. The fix is a habit: when you need to move several months and land on a sensible day, do it in one call.

Sometimes, though, a silent clamp is itself the wrong answer. If “January 31st + 1 month” represents 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 image of add/subtract is measuring the distance between two points. That’s the job of since and until, and 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. Often you want a specific shape, such as days, hours, or seconds, and you ask for it with largestUnit:

Temporal.Now.instant().since(user.createdAt, { largestUnit: 'hour' });
// → e.g. a Duration of 53 hours, 12 minutes, …

There’s one subtlety worth pausing on. Watch what happens if you ask for months between two Instants:

Temporal.Now.instant().since(user.createdAt, { largestUnit: 'month' });
// → throws RangeError

It throws, deliberately. A month has no fixed length: February is 28 or 29 days, and the months around a daylight-saving transition aren’t even a whole number of 24-hour days. Without a calendar to anchor against, “how many months” has no answer, so Temporal refuses to guess rather than hand you a plausible-looking but wrong result. Months and years are only meaningful on the types that carry a calendar, which are PlainDate and ZonedDateTime. The rule of thumb:

One boundary to keep in mind: this lesson computes the Duration. Turning it into the words “3 hours ago” is a separate, locale-aware concern, Intl.RelativeTimeFormat, and it belongs to the next chapter on internationalization. There’s no .toHuman() to look for here; you produce the duration, and the formatter renders it.

Sorting a list of invoices by due date, or checking whether one date is before another, is completely routine, and Temporal gives you exactly the names you’d expect.

For sorting, every type has a static compare that returns -1, 0, or 1, precisely the 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 those types. For yes/no questions, reach for the instance booleans a.equals(b), a.before(b), and a.after(b), which read like English at the call site.

Two threads from earlier are worth tying off here. First, the equality rule from Storage, domain, edge: compare instants with .equals() (or their .epochMilliseconds), never by stringifying them, because Postgres keeps microseconds while your code keeps milliseconds. That trailing precision drifts, so string equality fails on a difference that doesn’t matter. Second, you can’t compare across types: handing Temporal.PlainDate.compare a ZonedDateTime is a type error, the same locked-set discipline that keeps the storage types from being swapped for one another.

Sometimes you don’t want to shift a date by a span; you want to replace one component of it 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 you met in DST and recurring jobs: construct a wall-clock time in the user’s zone with with, then call .toInstant() to get the storable point. with is how you build the “9 AM in their zone” value before pinning it to the timeline.

One distinction is worth settling up front, because it trips people up. For the start of a calendar period, such as the first of the month or the first of the year, reach for with({ day: 1 }), a deliberate component edit. Don’t reach for round (covered next). Rounding snaps a value to a grid, which is the wrong tool for a period boundary; using it for “first of the month” produces something that looks almost right and isn’t.

Where with is a precise edit to one field, round snaps a whole value to a grid: to the nearest hour, the nearest 15 minutes, and so on. Two SaaS uses earn their keep.

The first is analytics bucketing. To chart events per hour, you floor every event’s timestamp down 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 that should only allow quarter-hour slots rounds whatever the user picked 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 . You don’t need the full catalog of modes: floor for bucketing and halfExpand for “nearest” cover the realistic cases. One more time, because it’s the common mistake: period starts are with, not round.

The conversion graph: moving between types

Section titled “The conversion graph: moving between types”

This is where the real thinking lives. You hold an Instant from the database and you need the calendar day it fell on for this user. You hold a PlainDate from a form and you need the exact instant of 5 PM on it in the user’s zone. Getting between the five types is the part people fail to reason through: they memorize one method and then apply it in the wrong direction.

So treat conversions as what they are: explicit reasoning steps, never coercion. There is no + that secretly turns one type into another, and 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();

The key idea is that a conversion chain reads like a sentence. Take the most common conversion in this chapter’s 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();

Start with an Instant, a fixed point in real time, straight off the timestamptz column. It’s a precise UTC moment, but it has no day yet: “which day” is meaningless until you say where.

const dayForUser = invoice.createdAt
.toZonedDateTimeISO(user.timeZone)
.toPlainDate();

Attach the user’s zone. Now the same moment has a wall clock: you know what the user’s clock read at that instant. The zone is named explicitly, which is the boundary where “where” gets decided.

const dayForUser = invoice.createdAt
.toZonedDateTimeISO(user.timeZone)
.toPlainDate();

Drop the clock, keep the date. The result is the calendar day the user would actually read off the page, the answer to “what day was this for them.”

1 / 1

Three calls, three explicit decisions: attach the zone, then drop to the day. Notice the zone is named at exactly the boundary that needs it, the same explicitness the storage lessons drilled into you. To navigate any other conversion, the question is always the same: I’m at this type, I need that type, which edge gets me there? Keep this map handy.

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, one you can build up, store, and parse back. That closes the wire-symmetry loop from Storage, domain, edge: the same way an Instant lives in memory but crosses every boundary as an ISO string, a Duration lives in memory as Temporal.Duration but crosses the wire as a string too.

Durations compose, and a duration is a perfectly valid argument to add:

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

The SaaS pattern this unlocks is configurable spans. 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. You 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 again, word for word: ISO 8601 strings cross every boundary, and Temporal types live only in memory. Duration is no exception: 'P30D' on the wire and in the database, Temporal.Duration in your code.

One thing to watch for has the same root cause as the months-throw from since. A duration that mixes calendar and exact units, like { months: 1, days: 5 }, is only well-defined against a calendar. Applied to a PlainDate or ZonedDateTime it’s fine, because those carry a calendar. There is no sensible way to apply months to a bare Instant, for the same reason there’s no sensible number of months between two of them.

Six things the experienced engineer never writes

Section titled “Six things the experienced engineer never writes”

Everything so far has a flip side: a 2010s habit it retires. If you’re arriving from another ecosystem, these six are probably in your muscle memory, so it’s worth replacing them deliberately. Each one isn’t merely old; it’s a bug class, and Temporal’s design rules it out structurally. You can’t zero-index a month you never pass positionally, you can’t mutate a value that’s immutable, and you can’t 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. Zero-indexed months, in-place mutation, naive millisecond math that ignores DST, the calendar-date-as-UTC-midnight trap, a bundle cost with no payoff, and stringly-typed comparison.

The date-fns / dayjs / moment / luxon line deserves a word, because those libraries were genuinely good. They aren’t wrong; they’re just no longer worth their cost. On this stack Temporal is the platform default: zero bundle, zero dependency, no wrapper. Reaching for a date library in 2026 means paying a bundle-size and maintenance cost 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 };
}

One question remains: Temporal exists, but can you ship it on the Node your project actually runs on? Native Temporal landed unflagged in Node 26, the course’s eventual deploy target, but most production SaaS today runs Node 24 LTS, which doesn’t ship it until Node 26 promotes to LTS in October 2026. So you bridge the gap with a polyfill.

Install one. temporal-polyfill (FullCalendar’s, the lean modern choice at roughly 20 KB) is the default for a new project; @js-temporal/polyfill (the TC39 champions’, larger but full-spec) is the alternative. Then, and this is the move that separates a codebase that ages well from one that doesn’t, route the polyfill through exactly one place. The seam lives in lib/temporal.ts, the single import path you’ve routed 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 lib/temporal.ts, never from the polyfill package directly, and the no-restricted-imports ESLint rule from earlier in the course enforces that single path mechanically. The payoff: the day you move to Node 26, you delete one line at this seam, and every consumer in the codebase keeps working untouched. The whole application is insulated from the runtime by a single file.

A quick word on the browser, so you don’t worry about a server-side concern. Chrome, Firefox, and Edge ship Temporal natively; Safari is still catching up. That gap doesn’t affect you here, because the projects in this course do their Temporal arithmetic on the server and send plain ISO 8601 strings to the client to render with Intl (next chapter). The polyfill only reaches the browser if you do date math client-side, which these projects don’t.

Time to write the actual computations. Implement the three functions below, which are the exact shapes from the billing-page ticket that opened this lesson, and the tests will check your work, including the month-end edge that catches everyone.

Here’s the whole billing page in one place: the three computations from the opening ticket, side by side. Each function is the job-to-be-done form: parse the stored string, do the arithmetic on the right type, hand back a value. Fill the four blanks with the shape that matches the comment above it.

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 the third function in your head on the date that catches everyone: a customer whose subscription 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());

You now hold the daily Temporal vocabulary: get “now” with the zone, shift with add/subtract, measure with since/until, compare, edit with with, bucket with round, convert along the graph, and treat durations as data. For the corners this lesson didn’t reach, such as the non-ISO calendars Temporal supports (Buddhist or Islamic, for instance, though this stack never reaches for them), these references are the canonical map.