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