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 — "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.

Balancing durations with and without relativeToTop row: 90 minutes always balances to 1 hour 30 minutes because minutes and hours are fixed-length. Bottom row: 45 days balances to about 1 month and 15 days when anchored to February 1, but 1 month and 14 days when anchored to March 1, because month length varies.Balancing depends on the unit (and the reference)PT90MPT1H30Mfixed units:no referenceneededP45DrelativeTo 2026-02-01P1M15DrelativeTo 2026-03-01P1M14Dsame raw inputdifferent start month length, different answer

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

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

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.