Date Arithmetic Without Mutations
Predictable, side-effect-free date math with the Temporal API. Part of Modern Date Logic with the Temporal API.
The legacy Date object mutates itself during arithmetic. Call setMonth() on a Date and the original instance changes in place β there is no new value, and the return type is a number (an epoch millisecond), not a date. In state-managed UIs this silently corrupts shared references: a Date stored in React state and then mutated by a helper breaks referential-equality checks, skips re-renders, or re-renders the wrong component. On the server, a single shared Date passed through an async billing pipeline produces race conditions where the computed due date depends on which callback ran last. The fix is value semantics: every arithmetic operation must return a brand-new immutable value and leave its input untouched. That is exactly what Temporal provides.
What actually breaks
The failures in date arithmetic almost never announce themselves as errors. Code that mutates a shared Date object, or that reaches for setMonth on a month-end, produces a value that is plausible β a real date, in the right ballpark β but wrong by a day or a month in a way that only a careful reader would catch. Because the result type-checks and renders fine, the bug slips through review and into production, where it surfaces later as a billing date that skipped a month, a reminder that fired on the wrong day, or a report whose totals do not reconcile. The category of problem is "silently plausible wrong answers," which is the most expensive kind to debug because nothing points you at the arithmetic.
Two structural weaknesses in legacy Date cause most of it. The first is mutation: setDate, setMonth, and their siblings modify the object in place and return a timestamp rather than a new date, so any other reference to that object sees the change too. The second is the absence of an overflow policy: adding a month to January 31 has no single correct answer, and legacy Date picks the surprising one β rolling forward into early March β without telling you. Immutable arithmetic with an explicit overflow policy addresses both at once, which is the through-line of this whole topic.
Three failure modes recur in production. First, hidden mutation: date.setDate(date.getDate() + 1) looks pure but rewrites the object every other line of code holds a reference to. Second, month-end rollover: legacy setMonth overflows January 31 + 1 month into early March instead of clamping to the end of February, silently shifting billing dates. Third, DST drift: treating "add a day" as "add 24 hours" produces an off-by-one-hour wall-clock time around spring-forward and fall-back transitions. Each is a data-correctness bug, not a crash, so it ships unnoticed and surfaces as a support ticket weeks later.
How mutation differs from immutable arithmetic
Mutation couples every holder of a date object to every operation performed on it. When addOneMonth(order.date) internally calls order.date.setMonth(...), it does not compute a new date β it rewrites the order's date as a side effect, and any code that captured order.date earlier now silently holds the mutated value. This aliasing is the source of a whole family of "how did this field change?" bugs, and it makes date logic impossible to reason about locally, because the effect of a function depends on who else is looking at the same object.
Immutable arithmetic severs that coupling. A Temporal value never changes after construction; every operation β add, subtract, with, round β returns a brand-new value and leaves the original exactly as it was. That single property means a date can be shared freely, cached, compared, and passed to any function without defensive copying, because no function can alter it out from under you. The mental model collapses to pure functions from values to values, which is far easier to test and to trust. The rest of this guide leans on that model: each operation is a transformation you can read in isolation, with no hidden state to track.
The diagram contrasts the legacy mutate-in-place model against Temporal's return-a-new-value model, including how overflow: 'constrain' and overflow: 'reject' diverge on an invalid month-end result.
API reference
The core arithmetic surface is small and symmetric. add and subtract take a duration-like object β { years, months, weeks, days, hours, minutes, seconds } β and return a new date or datetime shifted by that amount. until and since are their inverse: given two points, they return the Temporal.Duration between them, with a largestUnit option that controls whether you get, say, a count of days or a breakdown into years, months, and days. with replaces individual fields (setting the day to 1 to get a month start, for example) without touching the rest, and round snaps a value to a unit boundary. Every one of these returns a fresh immutable value.
The one option you will reach for most is overflow, which governs what happens when a field lands out of range β the January-31-plus-a-month case. It accepts 'constrain' (the default), which clamps the day to the last valid day of the target month, and 'reject', which throws so you can handle the ambiguity explicitly. Making this a first-class option is Temporal's way of forcing a decision that legacy Date made silently and badly. For zoned values, ZonedDateTime arithmetic additionally respects daylight-saving transitions, so "add one day" means the same wall-clock time the next day even when that day is 23 or 25 hours long.
| Method | Signature | Returns | Notes |
|---|---|---|---|
PlainDate.prototype.add |
add(durationLike, { overflow }) |
new PlainDate |
calendar-only; overflow defaults to 'constrain' |
PlainDate.prototype.subtract |
subtract(durationLike, { overflow }) |
new PlainDate |
mirror of add |
PlainDateTime.prototype.add |
add(durationLike, { overflow }) |
new PlainDateTime |
wall-clock, no zone |
ZonedDateTime.prototype.add |
add(durationLike, { overflow, disambiguation }) |
new ZonedDateTime |
DST-aware; disambiguation defaults to 'compatible' |
Instant.prototype.add |
add(durationLike) |
new Instant |
absolute units only β no years/months/weeks/days |
Temporal.Duration.prototype.add |
add(durationLike, { relativeTo }) |
new Duration |
needs relativeTo to balance calendar units |
Every method above is non-mutating; the receiver is never modified. The overflow policy decides what happens when a calendar result is invalid (e.g. February 31), and disambiguation decides what happens when a wall-clock time is invalid or ambiguous because of a DST transition. They are independent concerns.
Approach A: legacy Date (and why it fails)
Legacy arithmetic relies on the setter methods, which both mutate and overflow.
// Legacy Date math mutates the receiver and overflows month-ends.
const start = new Date(2024, 0, 31); // 31 Jan 2024 (month is 0-indexed)
const ref = start; // another reference to the SAME object
start.setMonth(start.getMonth() + 1); // Feb has no 31st -> rolls over
console.log(start.toDateString()); // 'Sat Mar 02 2024' β silently wrong
console.log(ref.toDateString()); // 'Sat Mar 02 2024' β ref mutated too!
Two problems in five lines: ref was supposed to keep the original date but it changed, and the intended "end of February" became early March. You can defend against mutation by cloning (new Date(start.getTime())) before every operation, and against overflow by manually checking getDate() after the call and clamping β but that is hand-rolled correctness logic on every call site. There is no overflow knob and no immutability guarantee in the platform.
Approach B: Temporal immutable arithmetic
Every Temporal arithmetic call returns a new instance and leaves the input alone.
import { Temporal } from '@js-temporal/polyfill';
const base = Temporal.PlainDate.from('2024-03-15');
const future = base.add({ days: 14 }); // returns a NEW PlainDate
const past = base.subtract({ days: 7 }); // base is never touched
console.assert(base.toString() === '2024-03-15'); // unchanged
console.assert(future.toString() === '2024-03-29');
console.assert(past.toString() === '2024-03-08');
Month-end results are governed by overflow. The default 'constrain' clamps the day to the last valid day of the target month; 'reject' throws so an upstream layer must decide.
import { Temporal } from '@js-temporal/polyfill';
const jan31 = Temporal.PlainDate.from('2024-01-31');
// Default 'constrain': clamps to Feb 29 (2024 is a leap year), not March.
const febSafe = jan31.add({ months: 1 });
console.assert(febSafe.toString() === '2024-02-29');
try {
const jan31NonLeap = Temporal.PlainDate.from('2023-01-31');
// 'reject': day 31 does not exist in Feb 2023, so this throws instead of clamping.
jan31NonLeap.add({ months: 1 }, { overflow: 'reject' });
} catch (e) {
console.error(e); // RangeError β force the caller to handle the ambiguity
}
Use 'constrain' for subscription billing where "the last day of the month" is the intended semantics, and 'reject' for strict financial or contract systems that must never silently shift a date. The dedicated guide on how to add months to a date without overflow using Temporal walks through the billing-cycle case end to end.
Production implementation
In production the arithmetic itself is rarely the whole story; the value comes from wrapping it in a function that validates its inputs, states its overflow policy, and returns a typed result. A billing-date helper, for instance, should reject a fractional month count up front, parse a string input through PlainDate.from so malformed data fails loudly, and take the overflow policy as a parameter so the caller chooses between clamping a month-end and rejecting it. Centralizing the decision in one function means the policy is applied consistently everywhere dates advance, rather than being re-decided β and re-fumbled β at each call site.
The same discipline scales to recurring schedules, proration, and cohort math. Because each operation is immutable, you can build a sequence of dates by folding add over a start value without any risk of the accumulator being mutated mid-loop, and you can compute several candidate dates from one anchor without cloning it. When the domain involves wall-clock semantics β "9am every day regardless of DST" β do the arithmetic on ZonedDateTime so transitions are handled for you; when it involves pure calendar dates β a birthday, a contract term β use PlainDate so no zone can perturb the result. Choosing the right type for the semantics is the main design decision, and the arithmetic follows from it.
A single hardened utility centralizes parsing, validation, and the overflow choice so call sites never touch raw setters. This is the pattern to export from a shared dates module.
import { Temporal } from '@js-temporal/polyfill';
type DateInput = string | Temporal.PlainDate;
type Overflow = 'constrain' | 'reject';
/** Add a calendar duration immutably, returning a new PlainDate. */
export function shiftDate(
input: DateInput,
duration: Temporal.DurationLike,
overflow: Overflow = 'constrain'
): Temporal.PlainDate {
// Temporal.PlainDate.from throws on malformed input β fail loud, not silent.
const date =
typeof input === 'string' ? Temporal.PlainDate.from(input) : input;
// The returned value is new; `date` (and the caller's variable) is untouched.
return date.add(duration, { overflow });
}
console.log(shiftDate('2024-01-31', { months: 1 }).toString()); // '2024-02-29'
console.log(shiftDate('2024-01-15', { years: 1, months: 2, days: 15 }).toString()); // '2025-03-30'
For SSR and serverless, prefer PlainDate/PlainDateTime whenever only the calendar value matters: they carry no timezone, so they cannot drift with the host machine's zone the way a Date rendered on a server in UTC versus a browser in America/Chicago would. When absolute moments matter β scheduling, audit logs β use ZonedDateTime with an explicit IANA zone; see working with ZonedDateTime objects for the instantiation and disambiguation patterns. Cache results by their ISO string key for memoization, since Temporal values serialize losslessly via toString().
When you need to compose, scale, or subtract spans of time as first-class values rather than applying them to a date, reach for Temporal.Duration; the rules for balancing calendar units are covered in Temporal duration arithmetic.
Edge cases
The month-end overflow is the canonical edge case and the reason the overflow option exists: adding one month to January 31 must land somewhere, and both "February 28/29" (constrain) and "throw" (reject) are defensible depending on whether you are scheduling a recurring event or validating a user-entered date. Closely related is the sequence problem β advancing month by month from January 31 β where clamping at each step "loses" the 31 permanently (Jan 31 β Feb 28 β Mar 28), whereas re-deriving each date from the original anchor preserves the intent of "the 31st, clamped." Which behavior you want depends on the domain, and the immutable model makes either one easy to express deliberately.
Daylight-saving transitions are the other major edge case, and they only apply to zoned values. Adding 24 hours across a spring-forward night is not the same as adding one calendar day: the former lands an hour off the wall clock, the latter keeps 9am at 9am. ZonedDateTime distinguishes { hours: 24 } from { days: 1 } precisely so you can say which you mean. Leap years, leap seconds (which Temporal deliberately does not model, smoothing them away), and the proleptic Gregorian calendar for historical dates round out the cases where naive arithmetic drifts and Temporal's calendar-aware operations stay correct.
Calendar-day vs absolute-hour addition across DST
On ZonedDateTime, add({ days: 1 }) keeps the wall-clock time and lets the UTC offset move; add({ hours: 24 }) adds exactly 24 Γ 3600 real seconds and lets the wall clock move. They diverge on transition days.
import { Temporal } from '@js-temporal/polyfill';
// Spring-forward: 10 Mar 2024 in New York is a 23-hour day (02:00->03:00 skipped).
const zdt = Temporal.ZonedDateTime.from('2024-03-09T02:00:00-05:00[America/New_York]');
// Calendar add: wall clock stays 02:00, offset shifts EST -> EDT.
console.log(zdt.add({ days: 1 }).toString()); // '2024-03-10T02:00:00-04:00[America/New_York]'
// Absolute add: exactly 24 UTC hours later, which is 03:00 wall-clock on a 23-hour day.
console.log(zdt.add({ hours: 24 }).toString()); // '2024-03-10T03:00:00-04:00[America/New_York]'
Month-end rollover
'constrain' clamps 31 January + 1 month to the last day of February (29 in a leap year, 28 otherwise) instead of overflowing into March. This is the single most common legacy billing bug.
Leap-day anchoring
Adding one year to 2024-02-29 under 'constrain' yields 2025-02-28; 'reject' throws. Decide explicitly whether a Feb-29 anniversary should fall back to Feb 28 or be flagged.
Mixing Date and Temporal
Never put a legacy Date into a Temporal arithmetic chain. Convert first with Temporal.Instant.fromEpochMilliseconds(d.getTime()), then .toZonedDateTimeISO(zone); implicit coercion otherwise yields raw epoch milliseconds, not a calendar value.
Gotchas & common pitfalls
- Assuming
.add()mutates β it never does; capture the return value (x = x.add(...)), it is notx.add(...)in place. - Confusing calendar and absolute units β
add({ days: 1 })βadd({ hours: 24 })on transition days; pick wall-clock vs elapsed-time semantics deliberately. - Forgetting
overflowβ the silent default is'constrain'; pass'reject'when a clamped date would be a correctness bug. - Calendar units on
InstantβInstant.add({ months: 1 })throws;Instantonly understands absolute units. UsePlainDate/PlainDateTime/ZonedDateTimeforyears/months/weeks/days. - Mixing
Datewith Temporal β convert throughInstant.fromEpochMillisecondsfirst; never coerce aDatedirectly.
Testing checklist
| Scenario | Input | Expected |
|---|---|---|
| Immutability | base.add({ days: 1 }) |
base string unchanged |
| Month-end constrain | '2024-01-31'.add({ months: 1 }) |
2024-02-29 |
| Month-end non-leap | '2023-01-31'.add({ months: 1 }) |
2023-02-28 |
| Month-end reject | '2023-01-31'.add({ months: 1 }, { overflow: 'reject' }) |
throws RangeError |
| Calendar day over DST | '2024-03-09T02:00β¦NY'.add({ days: 1 }) |
02:00-04:00 |
| Absolute hours over DST | '2024-03-09T02:00β¦NY'.add({ hours: 24 }) |
03:00-04:00 |
Run the suite under multiple host zones to catch any accidental dependence on the machine's clock:
# Re-run the same tests under three host zones; results must be identical.
for tz in UTC America/New_York Australia/Lord_Howe; do TZ=$tz npm test; done
Frequently Asked Questions
How does Temporal prevent mutation side effects compared to the legacy Date object?
Temporal types are value-based and strictly immutable. Methods like .add() and .subtract() always return a new instance and never modify the receiver, so a value stored in React state, Redux, or shared across async callbacks cannot be silently changed by a helper. Legacy Date setters mutate in place, which is the root of shared-reference bugs.
What happens when I add one month to January 31st?
By default (overflow: 'constrain'), PlainDate.add({ months: 1 }) clamps to February 29 in a leap year or February 28 otherwise. With overflow: 'reject' it throws a RangeError, which is the right choice for strict billing systems that must never silently shift a date.
Is Temporal arithmetic safe across DST boundaries?
Yes, with Temporal.ZonedDateTime. Adding calendar units (days, months) preserves local wall-clock time and adjusts the UTC offset across transitions, while adding absolute units (hours, seconds) preserves exact elapsed UTC time. Choose based on whether you want wall-clock or elapsed-time semantics.
Can I use immutable date math in legacy browsers without polyfills?
Not yet everywhere. Where Temporal is not native, install @js-temporal/polyfill and pin the version. Isolate all date arithmetic behind pure utility functions so swapping to the native implementation later is a one-line change.
Why should date arithmetic be immutable?
Because mutation couples every holder of a date object to every operation on it. Legacy setDate/setMonth rewrite the object in place, so code that captured the date earlier silently sees the change, producing aliasing bugs that are hard to trace. Temporal values never change after construction β add, subtract, with, and round all return new values β so a date can be shared, cached, and passed around without defensive copying, and each operation reads as a pure function you can test in isolation.
What does the overflow option do in Temporal date arithmetic?
It decides what happens when an operation lands a field out of range, such as adding a month to January 31. 'constrain' (the default) clamps the day to the last valid day of the target month, giving February 28 or 29; 'reject' throws so you can handle the ambiguity explicitly. Legacy Date made this choice silently and surprisingly by rolling forward into early March. Making overflow a first-class option forces a deliberate decision that matches your domain.
How is adding a day different from adding 24 hours across DST?
On a ZonedDateTime they differ across a daylight-saving transition. Adding { days: 1 } keeps the same wall-clock time the next day even when that day is 23 or 25 hours long, which is what a scheduler usually wants. Adding { hours: 24 } moves exactly 24 hours of absolute time, which lands an hour off the wall clock on a transition night. Temporal keeps the two distinct so you can express whichever the domain requires; legacy millisecond arithmetic conflates them.