Temporal.Duration Arithmetic
This guide covers how to construct, add, subtract, round, and balance Temporal.Duration values correctly — including why calendar units need a relativeTo reference and how durations behave across DST transitions. Part of Modern Date Logic with the Temporal API.
Problem Framing
A duration is a length of time — "two hours," "three months," "90 days" — and the reason it deserves its own type and its own guide is that durations do not behave like numbers once calendar units are involved. Two hours plus fifty minutes is always two hours fifty minutes, but "one month" is not a fixed quantity: added to January 15 it advances 31 days, added to February 15 it advances 28 or 29. This means a duration containing months or years has no single length until you anchor it to a starting date, and any code that treats it as a fixed number of days is quietly approximating. Temporal.Duration exists to represent these lengths honestly, keeping the units distinct rather than prematurely collapsing them to a possibly-wrong total.
The framing that keeps duration code correct is to separate the two regimes explicitly. Fixed units — days and below, in the sense of clock time — can be added, subtracted, and totalled freely, because their lengths do not vary. Calendar units — weeks, months, years — require a reference point (relativeTo) whenever you need to balance or total them, because their lengths depend on the calendar position. Recognizing which regime an operation lives in, and supplying the anchor when the calendar regime demands it, is the whole discipline; the rest is API surface.
A duration is a length of time — "2 months 10 days", "90 minutes", "45 days". The trap is that years, months, and weeks are not fixed-length: a month can be 28–31 days, and a calendar day can be 23, 24, or 25 hours across a DST boundary. So a duration like P45D has no single answer to "how many months is that?" until you anchor it to a starting point. Get this wrong and you ship reporting bugs (a 90-day window summed as "3 months exactly"), scheduling drift, and totals that silently change depending on the host machine's clock. Temporal.Duration makes the ambiguity explicit: any operation that crosses a variable-length boundary refuses to guess and demands a relativeTo reference.
The hardest idea on this page is balancing: turning a duration with large raw component values (or mixed sign) into a normalized one, where the result depends on the reference point. The diagram below shows the same two raw durations balanced two different ways.
API Reference
| Method / property | Signature | Returns | Caveat |
|---|---|---|---|
Temporal.Duration.from(x) |
(string | object) |
Duration |
Parses ISO 8601 duration strings (P1Y2M10DT2H30M) or property bags. |
.add(other, opts?) |
(Duration | object, { relativeTo? }) |
Duration |
Needs relativeTo if either operand has years/months/weeks. |
.subtract(other, opts?) |
(Duration | object, { relativeTo? }) |
Duration |
Same relativeTo rule as .add(). |
.round(opts) |
({ largestUnit?, smallestUnit?, roundingIncrement?, roundingMode?, relativeTo? }) |
Duration |
Balancing/rounding across calendar units requires relativeTo. |
.total(opts) |
({ unit, relativeTo? }) |
number |
A single fractional number in unit; calendar units need relativeTo. |
.negated() |
() |
Duration |
Flips the sign of every component. |
.abs() |
() |
Duration |
Forces all components non-negative. |
.sign |
property | -1 | 0 | 1 |
Overall direction of the duration. |
.blank |
property | boolean |
true if every component is zero. |
Temporal.Duration.compare(a, b, opts?) |
(Duration, Duration, { relativeTo? }) |
-1 | 0 | 1 |
Calendar-unit durations cannot be compared without relativeTo. |
relativeTo accepts a Temporal.PlainDate, Temporal.PlainDateTime, or Temporal.ZonedDateTime. Use a ZonedDateTime whenever DST matters — only it knows that some days are 23 or 25 hours long.
Constructing a Duration
There are two ways in: an ISO 8601 duration string, or a property bag.
import { Temporal } from '@js-temporal/polyfill';
// ISO 8601: P = period, T separates the time part. P1Y2M10DT2H30M
const fromString = Temporal.Duration.from('P1Y2M10DT2H30M');
// Property bag — any subset of fields, all default to 0
const fromBag = Temporal.Duration.from({ hours: 2, minutes: 30 });
console.log(fromString.toString()); // 'P1Y2M10DT2H30M'
console.log(fromBag.toString()); // 'PT2H30M'
// Negative durations: every component is negative, or use .negated()
const past = Temporal.Duration.from({ days: -3 });
console.log(past.sign); // -1
A Duration is just a record of component fields (years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds). It carries no anchor and no calendar — which is exactly why some operations later demand a relativeTo.
Approach A: Hand-Rolled Duration Math with Legacy Date
Before Temporal, "duration arithmetic" meant juggling epoch milliseconds and praying no calendar unit was involved.
// Legacy: subtract two timestamps to get elapsed milliseconds
const start = new Date('2026-02-01T00:00:00Z').getTime();
const end = new Date('2026-03-18T00:00:00Z').getTime();
const elapsedMs = end - start;
const days = elapsedMs / (1000 * 60 * 60 * 24); // 45 — but only because both ends are UTC midnight
console.log(days); // 45
This works for fixed units (hours, minutes, days as exactly 86,400 s) but breaks the moment you want months or years, or the moment a DST transition makes a local day 23 or 25 hours long. Dividing by 86400000 quietly assumes every day is 24 hours. There is no safe legacy way to say "45 days is how many months?" because the answer depends on which months.
Approach B: Balancing and Totalling with Temporal
Temporal refuses to guess. Adding two durations that only contain fixed-length units works with no reference:
import { Temporal } from '@js-temporal/polyfill';
const a = Temporal.Duration.from({ hours: 1, minutes: 40 });
const b = Temporal.Duration.from({ minutes: 50 });
// Fixed units only → safe to balance without relativeTo
const sum = a.add(b);
console.log(sum.toString()); // 'PT2H30M' (100 minutes balanced into 2h30m)
But the instant a calendar unit (years, months, weeks) appears, you must supply relativeTo, or Temporal throws a RangeError:
import { Temporal } from '@js-temporal/polyfill';
const span = Temporal.Duration.from({ days: 45 });
// relativeTo is a PlainDate here; balancing 45 days into months
// depends on the lengths of the months that follow the start date
const fromFeb = span.round({ largestUnit: 'month', relativeTo: '2026-02-01' });
console.log(fromFeb.toString()); // 'P1M15D' (Feb has 28 days in 2026, so 45 = 28 + 17... balanced as 1 month + 15 days)
const fromMar = span.round({ largestUnit: 'month', relativeTo: '2026-03-01' });
console.log(fromMar.toString()); // 'P1M14D' (March has 31 days, so the remainder differs)
// No relativeTo with a calendar largestUnit → throws
try {
span.round({ largestUnit: 'month' });
} catch (e) {
console.error(e); // RangeError: a starting point is required for months balancing
}
.round() — balancing and snapping
.round() does two jobs at once: it balances components up to largestUnit and snaps the smallest retained component to smallestUnit.
import { Temporal } from '@js-temporal/polyfill';
const messy = Temporal.Duration.from({ minutes: 90, seconds: 30 });
// largestUnit rolls minutes up into hours; smallestUnit drops sub-minute detail
const tidy = messy.round({ largestUnit: 'hour', smallestUnit: 'minute' });
console.log(tidy.toString()); // 'PT1H31M' (90m30s → 1h30m30s, rounded to nearest minute)
.total() — collapse to one number
When you want a single scalar ("how many hours is this duration?"), use .total(). It returns a fractional number, not a Duration.
import { Temporal } from '@js-temporal/polyfill';
const d = Temporal.Duration.from({ hours: 1, minutes: 30 });
console.log(d.total({ unit: 'minute' })); // 90
// Calendar units still need an anchor
const month = Temporal.Duration.from({ months: 1 });
console.log(
month.total({ unit: 'day', relativeTo: '2026-02-01' }) // 28 — February 2026
);
console.log(
month.total({ unit: 'day', relativeTo: '2026-03-01' }) // 31 — March
);
.negated(), .abs(), and sign
import { Temporal } from '@js-temporal/polyfill';
const overdue = Temporal.Duration.from({ days: -5 });
console.log(overdue.negated().toString()); // 'P5D' (flip sign of every field)
console.log(overdue.abs().toString()); // 'P5D' (force non-negative)
console.log(overdue.sign); // -1
console.log(overdue.blank); // false
A Duration cannot mix signs in a meaningful balanced form — .abs() and .negated() operate on the whole value, not per field.
Production Implementation
A reusable helper that balances any duration against a reference and validates input. Pass a ZonedDateTime reference when DST correctness matters; see Working with ZonedDateTime Objects for constructing one.
import { Temporal } from '@js-temporal/polyfill';
type RelativeRef =
| Temporal.PlainDate
| Temporal.PlainDateTime
| Temporal.ZonedDateTime
| string;
/**
* Balance a duration up to `largestUnit`, anchored at `relativeTo`.
* Throws clearly if a calendar unit is requested without an anchor.
*/
function balanceDuration(
input: Temporal.Duration | string,
largestUnit: Temporal.DateTimeUnit,
relativeTo?: RelativeRef
): Temporal.Duration {
const duration =
typeof input === 'string' ? Temporal.Duration.from(input) : input;
const calendarUnit = ['year', 'month', 'week'].includes(largestUnit);
if (calendarUnit && relativeTo == null) {
// Fail loudly rather than letting Temporal throw a less obvious RangeError
throw new TypeError(
`Balancing to "${largestUnit}" requires a relativeTo reference.`
);
}
return duration.round({ largestUnit, relativeTo });
}
// On the server, anchor with an explicit zone so the host clock is irrelevant
const ref = Temporal.ZonedDateTime.from('2026-03-08T00:00:00[America/New_York]');
console.log(balanceDuration({ hours: 48 }, 'day', ref).toString());
// 'P2D' here resolves correctly even though March 8 → 10 contains a DST gap
For SSR and serverless, never rely on the ambient time zone: pass an explicit ZonedDateTime (or at minimum a PlainDate) as relativeTo so the result is deterministic across regions.
Edge Cases
The daylight-saving edge case is where durations reveal their subtlety most sharply. Measured against a ZonedDateTime, "one day" and "24 hours" are different lengths across a transition — the day is 23 or 25 hours — so total({ unit: 'hour', relativeTo: zdt }) for a one-day duration returns 23 or 25 on those dates, not 24. This is correct, and code that assumed a day is always 24 hours will disagree with it; the disagreement is the bug being surfaced, not introduced. Whenever a duration's total matters and a zone is in play, anchor it to a ZonedDateTime so transitions are accounted for.
The month-end and leap-year cases are the calendar-unit counterparts. Adding "one month" to a month-end invokes the same overflow decision as ordinary date arithmetic, and totalling a duration that spans a leap day depends on the anchor because the number of days in "one year" from that anchor might be 365 or 366. A related subtlety is that duration arithmetic is not always commutative in the presence of calendar units: adding months then days can differ from adding days then months, because the intermediate anchor's month length differs. Order operations to match the real-world sequence you actually mean, and the results stay intuitive.
DST: durations across a ZonedDateTime
This is the headline reason relativeTo exists. Across a spring-forward day, a local "1 day" is only 23 hours of absolute time; across fall-back it is 25.
import { Temporal } from '@js-temporal/polyfill';
// March 8, 2026 spring-forward in America/New_York: that local day is 23 hours
const ref = Temporal.ZonedDateTime.from('2026-03-08T00:00:00[America/New_York]');
const oneDay = Temporal.Duration.from({ days: 1 });
// Anchored to a ZonedDateTime, 1 calendar day totals only 23 hours of real time
console.log(oneDay.total({ unit: 'hour', relativeTo: ref })); // 23
// Anchored to a PlainDate (no zone), a day is always 24 hours
console.log(oneDay.total({ unit: 'hour', relativeTo: '2026-03-08' })); // 24
Use a ZonedDateTime relativeTo for elapsed-time reporting; a PlainDate for nominal calendar reporting.
Fall-back: the 25-hour day
import { Temporal } from '@js-temporal/polyfill';
// November 1, 2026 fall-back in America/New_York: 25-hour local day
const ref = Temporal.ZonedDateTime.from('2026-11-01T00:00:00[America/New_York]');
console.log(Temporal.Duration.from({ days: 1 }).total({ unit: 'hour', relativeTo: ref })); // 25
Comparing durations
Temporal.Duration.compare needs relativeTo whenever either side contains calendar units — there is no way to know if "1 month" is longer than "30 days" without a start point.
import { Temporal } from '@js-temporal/polyfill';
const month = Temporal.Duration.from({ months: 1 });
const thirty = Temporal.Duration.from({ days: 30 });
// February: 1 month (28d) < 30d
console.log(Temporal.Duration.compare(month, thirty, { relativeTo: '2026-02-01' })); // -1
// March: 1 month (31d) > 30d
console.log(Temporal.Duration.compare(month, thirty, { relativeTo: '2026-03-01' })); // 1
Gotchas & Common Pitfalls
- Calling
.add()/.subtract()/.round()/.total()with calendar units and norelativeTo. It throwsRangeError. Fix: always pass arelativeTowhen years/months/weeks are involved. - Using a
PlainDatereference for DST-sensitive elapsed time. APlainDatetreats every day as 24 hours. Fix: pass aZonedDateTimewhen you need real elapsed time. - Assuming
.total({ unit: 'month' })is stable. The same duration totals differently depending on the anchor month's length. Fix: pinrelativeToto your domain's reference date. - Comparing durations with
</>operators. Durations are objects; relational operators coerce toNaN-ish nonsense. Fix: useTemporal.Duration.compare. - Expecting balanced output without rounding. Raw arithmetic keeps large component values; only
.round()(withlargestUnit) balances. Fix: round explicitly when you want normalized output.
Testing Checklist
| Scenario | Input | Expected |
|---|---|---|
| Fixed-unit balance | from({minutes:100}).round({largestUnit:'hour'}) |
PT1H40M |
| Calendar balance (Feb) | from({days:45}).round({largestUnit:'month', relativeTo:'2026-02-01'}) |
P1M15D |
| Calendar balance (Mar) | from({days:45}).round({largestUnit:'month', relativeTo:'2026-03-01'}) |
P1M14D |
| Spring-forward day total | from({days:1}).total({unit:'hour', relativeTo: ny('2026-03-08')}) |
23 |
| Fall-back day total | from({days:1}).total({unit:'hour', relativeTo: ny('2026-11-01')}) |
25 |
| Missing anchor | from({months:1}).total({unit:'day'}) |
throws RangeError |
Run the suite under multiple host zones to confirm a fixed relativeTo makes results deterministic:
# Same expected output regardless of the host machine's zone
TZ=UTC npm test && TZ=Asia/Kolkata npm test && TZ=America/Los_Angeles npm test
Frequently Asked Questions
Why do I get a RangeError when adding two durations?
Because at least one operand contains years, months, or weeks — units whose length is not fixed. Temporal cannot balance them without knowing where they start, so it requires relativeTo. Pass a PlainDate, PlainDateTime, or ZonedDateTime and the error disappears.
When should relativeTo be a ZonedDateTime instead of a PlainDate?
Use a ZonedDateTime whenever the answer should reflect real elapsed time across DST — a "1 day" duration anchored to a spring-forward date totals 23 hours, not 24. Use a PlainDate for nominal calendar counting where every day is treated as 24 hours.
What is the difference between .round() and .total()?
.round() returns a balanced Temporal.Duration (e.g. P1M15D). .total() collapses the whole duration into a single fractional number in one unit (e.g. 90 minutes). Use .round() for display components, .total() for math and comparisons.
Can I compare two durations directly?
Not with < or >. Use Temporal.Duration.compare(a, b, { relativeTo }). The relativeTo is mandatory whenever either duration contains calendar units, because "1 month" versus "30 days" has no answer without a starting month.
When does a Temporal.Duration need a relativeTo anchor?
Whenever the operation involves calendar units — weeks, months, or years — and you need a balanced or totalled result. Those units are variable-length: a month is 28–31 days and a year 365 or 366, so the duration has no single length until anchored to a starting date. Fixed units (days and below as clock time) can be added, subtracted, and totalled with no anchor. Supplying relativeTo when calendar units are present is what prevents the silent 30-day-month approximation.
Why is one day sometimes not 24 hours in duration math?
Because on a daylight-saving transition day, measured against a ZonedDateTime, the calendar day is 23 or 25 hours long. So totalling a one-day duration to hours with a zoned relativeTo correctly returns 23 or 25 on those dates rather than 24. Code that assumed a day is always 24 hours disagrees with this result — that disagreement is a real bug being surfaced. Anchor durations to a ZonedDateTime whenever their totals matter and a zone is involved.
Is Temporal duration arithmetic commutative?
For fixed units, yes. With calendar units it is not always: adding months then days can differ from adding days then months, because the intermediate anchor month has a different length, and month-end additions invoke an overflow decision. Order your operations to match the real-world sequence you mean — for example, apply the calendar shift the domain performs first — and the results stay intuitive and correct.