Add and Subtract Durations with Temporal
To add or subtract two durations, call .add() or .subtract() on a Temporal.Duration; when either operand contains years, months, or weeks you must pass a relativeTo reference so the result can be balanced. Part of Temporal.Duration Arithmetic.
Why This Scenario Is Tricky
Adding two durations seems like it should be pure arithmetic, and for fixed units it is — but the moment calendar units enter, "add" stops having a single answer without more context. Hours, minutes, and seconds are fixed-length, so 1h40m + 50m unambiguously balances to 2h30m with no reference point needed. Years, months, and weeks are variable-length: a month can be 28 to 31 days, a year 365 or 366, so "1 month + 30 days" cannot be balanced into a single figure until you know which month you are measuring from. This is why Temporal requires a relativeTo anchor for any duration operation that mixes or balances calendar units, and why omitting it either throws or leaves the units unbalanced.
The trap for people coming from libraries that "just add" is that those libraries silently assume a month is 30 days (or 365/12), producing answers that are quietly wrong by a day or more. Temporal refuses to guess: it makes you supply the anchor when the math genuinely depends on it, which feels like extra friction but is exactly what prevents the silent drift. Understanding which operations need relativeTo and which do not is the key to using duration arithmetic correctly.
Adding PT40M to PT50M is unambiguous — minutes and hours are fixed-length, so the answer is always PT1H30M. But adding P1M (one month) to P10D (ten days) has no fixed answer: the result in days depends entirely on which month you started in, because February, March, and April have different lengths. Temporal.Duration does not carry a start date, so it physically cannot balance a calendar-unit sum on its own.
That is why .add() and .subtract() accept a relativeTo option. Without it, any operand containing years, months, or weeks throws a RangeError. With it, Temporal walks the calendar from that anchor and produces a correctly balanced duration. The same arithmetic anchored to two different months produces two different day counts — which surprises people who expect duration math to be a pure function of its inputs. It is, but the inputs include the anchor.
Minimal Working Solution
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 → no relativeTo needed; 100 minutes balances to 1h40m... + 50m = 2h30m
const sum = a.add(b);
console.log(sum.toString()); // 'PT2H30M'
// Subtract works the same way
const diff = a.subtract(b);
console.log(diff.toString()); // 'PT50M'
For calendar units, supply an anchor:
import { Temporal } from '@js-temporal/polyfill';
const oneMonth = Temporal.Duration.from({ months: 1 });
const tenDays = Temporal.Duration.from({ days: 10 });
// relativeTo anchors the months so the result can be balanced into days
const total = oneMonth.add(tenDays, { relativeTo: '2026-02-01' });
console.log(total.toString()); // 'P1M10D' (kept as 1 month + 10 days, balanced from Feb 1)
Full Production Version
In production the rule is simple to state: if every unit involved is fixed (hours and below), you can add, subtract, and total durations freely with no anchor; if any calendar unit is involved and you need a balanced or total result, pass relativeTo — a PlainDate or ZonedDateTime that says "measured from here." For a ZonedDateTime anchor, balancing also respects daylight-saving, so "1 day" measured across a transition correctly accounts for the 23- or 25-hour day. Choosing the anchor deliberately is the whole game: it encodes the calendar context the duration is meaningless without.
Temporal.Duration.total({ unit, relativeTo }) is the companion operation, collapsing a duration to a single number in a chosen unit — total hours, total days — again requiring the anchor when calendar units are present. Use it for comparisons and thresholds ("has more than 90 days elapsed"), and prefer it to manually converting units, which reintroduces the 30-day-month fallacy. When you subtract durations, the same anchoring rules apply, and a negative result is represented by negative fields rather than an error, so check the sign with Temporal.Duration.compare(d, {}) when you need to know whether one duration exceeds another. Keeping the fixed-unit and calendar-unit cases mentally separate is what keeps the code correct.
A typed helper that validates the operands, decides whether an anchor is required, and balances the result up to a chosen largestUnit.
import { Temporal } from '@js-temporal/polyfill';
type DurationInput = Temporal.Duration | string | Temporal.DurationLike;
type RelativeRef =
| Temporal.PlainDate
| Temporal.PlainDateTime
| Temporal.ZonedDateTime
| string;
const CALENDAR_FIELDS = ['years', 'months', 'weeks'] as const;
function hasCalendarUnit(d: Temporal.Duration): boolean {
// years/months/weeks are variable-length; their presence forces relativeTo
return CALENDAR_FIELDS.some((f) => d[f] !== 0);
}
/**
* Add (or subtract) two durations and balance the result.
* @param op 'add' | 'subtract'
* @param largestUnit unit to balance up to (e.g. 'month', 'hour')
*/
function combineDurations(
base: DurationInput,
other: DurationInput,
op: 'add' | 'subtract',
largestUnit: Temporal.DateTimeUnit,
relativeTo?: RelativeRef
): Temporal.Duration {
const a = Temporal.Duration.from(base);
const b = Temporal.Duration.from(other);
const needsAnchor =
hasCalendarUnit(a) ||
hasCalendarUnit(b) ||
['year', 'month', 'week'].includes(largestUnit);
if (needsAnchor && relativeTo == null) {
// Fail with a clear message instead of a bare RangeError from deep in Temporal
throw new TypeError('relativeTo is required when calendar units are involved.');
}
// .add()/.subtract() balance up to the largest input unit by default;
// a follow-up .round() lets us pin the output to an explicit largestUnit
const combined = op === 'add' ? a.add(b, { relativeTo }) : a.subtract(b, { relativeTo });
return combined.round({ largestUnit, relativeTo });
}
// Calendar example: 1 month + 20 days, balanced into months/days from Feb 1
console.log(
combineDurations({ months: 1 }, { days: 20 }, 'add', 'month', '2026-02-01').toString()
); // 'P1M20D'
// Subtract: 2 months − 10 days from March 1
console.log(
combineDurations({ months: 2 }, { days: 10 }, 'subtract', 'day', '2026-03-01').toString()
); // 'P49D' (Mar 1 + 2 months = May 1 = 61 days; minus 10 = 51... balanced to days)
Verification Snippet
This block proves the headline behaviour: the same addition produces different balanced day counts depending on the relativeTo month, and fixed-unit math is anchor-independent.
import { Temporal } from '@js-temporal/polyfill';
// 1) Fixed units never need an anchor and never change
const fixed = Temporal.Duration.from({ hours: 1, minutes: 40 })
.add({ minutes: 50 });
console.assert(fixed.toString() === 'PT2H30M', 'fixed-unit add should be 2h30m');
// 2) relativeTo affects month balancing.
// Adding 5 days to "1 month", then totalling in days:
const span = Temporal.Duration.from({ months: 1 }).add({ days: 5 }, { relativeTo: '2026-02-01' });
// February 2026 has 28 days, so 1 month + 5 days from Feb 1 = 33 days
const daysFromFeb = span.total({ unit: 'day', relativeTo: '2026-02-01' });
console.assert(daysFromFeb === 33, `expected 33, got ${daysFromFeb}`);
// The SAME duration value, totalled from March 1 (31-day month), is 36 days
const daysFromMar = span.total({ unit: 'day', relativeTo: '2026-03-01' });
console.assert(daysFromMar === 36, `expected 36, got ${daysFromMar}`);
// 3) Missing anchor on a calendar-unit add throws
let threw = false;
try {
Temporal.Duration.from({ months: 1 }).add({ days: 5 });
} catch {
threw = true;
}
console.assert(threw, 'calendar-unit add without relativeTo must throw');
console.log('all duration add/subtract assertions passed');
Common Pitfalls
The dominant pitfall is trying to balance or total a duration that contains calendar units without a relativeTo anchor, which either throws or leaves you with an unbalanced result you then misread. Supply the anchor whenever years, months, or weeks are involved and you need a single figure. The mirror mistake is importing the habit from other libraries of treating a month as 30 days or a year as 365; that assumption is wrong for most specific date ranges and is exactly what the anchor requirement exists to prevent.
A second pitfall is forgetting that a ZonedDateTime anchor makes balancing DST-aware, and being surprised when "24 hours" and "1 day" differ across a transition — that difference is correct, not a bug. A third is assuming duration addition commutes with calendar context: add(months).add(days) can differ from add(days).add(months) because the intermediate anchor month differs, so order your operations to match the real-world sequence you mean. Finally, do not hand-roll duration balancing with modulo math; add, subtract, and total already balance correctly given an anchor, and reimplementing it is how off-by-one errors at unit boundaries creep back in.
-
Forgetting
relativeToon calendar-unit math.Temporal.Duration.from({ months: 1 }).add({ days: 5 }); // ✗ RangeError Temporal.Duration.from({ months: 1 }).add({ days: 5 }, { relativeTo: '2026-02-01' }); // ✓ -
Assuming the result is anchor-independent. Two anchors give two day totals.
span.total({ unit: 'day' }); // ✗ throws (no anchor) span.total({ unit: 'day', relativeTo: '2026-02-01' }); // ✓ deterministic -
Subtracting in the wrong direction and ignoring sign.
.subtract()can yield a negative duration; check.signbefore formatting.const d = Temporal.Duration.from({ days: 3 }).subtract({ days: 10 }); // sign -1 const display = d.sign < 0 ? d.abs() : d; // ✓ normalize for UI
Frequently Asked Questions
Do I always need relativeTo to add durations?
No. If both durations contain only fixed-length units (hours, minutes, seconds, and below — plus days when no zone matters), .add() and .subtract() work without it. You only need relativeTo when years, months, or weeks are present, or when you balance up to one of those units.
Why does the same sum total to a different number of days?
Because months have different lengths. "1 month + 5 days" from February 1 covers a 28-day month, totalling 33 days; from March 1 it covers a 31-day month, totalling 36. The duration value is the same — the day total depends on the anchor you measure it from.
How do I keep the result as months and days instead of getting raw days?
Pass largestUnit: 'month' to .round() (with relativeTo) after combining. Without rounding up to month, balancing may express the value in the largest unit already present in the operands.
How do I add two durations together in Temporal?
Call a.add(b) on a Temporal.Duration. If every unit is fixed (hours, minutes, seconds), it balances with no anchor — 1h40m + 50m gives PT2H30M. If any calendar unit (years, months, weeks) is involved and you need a balanced result, pass { relativeTo } — a PlainDate or ZonedDateTime — because those units are variable-length and cannot be balanced without knowing the date they are measured from.
Why does Temporal need relativeTo to add some durations?
Because years, months, and weeks are variable-length: a month is 28–31 days and a year is 365 or 366, so a mixed duration like '1 month + 30 days' cannot be reduced to a single figure until you know which month you are measuring from. relativeTo supplies that anchor. Fixed units (hours and below) do not need it. Requiring the anchor is what stops the silent 30-day-month assumption that makes other libraries' results drift.
How do I convert a duration to a single total, like total days?
Use Temporal.Duration.total({ unit: 'day', relativeTo }). It collapses the duration to one number in the chosen unit, requiring the relativeTo anchor when calendar units are present. Prefer it over manually dividing, which reintroduces the fixed-length-month fallacy. It is ideal for thresholds and comparisons such as checking whether more than ninety days have elapsed.