skip to content

Temporal.PlainDate objects are immutable. What does date.add({ days: 1 }) return, and how do you produce a copy with a different month?

level: juniorimportance: should knowfreq 38%

answer

  1. nothing is edited in place
  2. the return value is the result
  3. no setters, one copy operation
  4. objects, so === is the wrong tool
  5. one operator throws on purpose

basics

~10 s

It returns a brand-new Temporal.PlainDate and leaves the original untouched. To change one field, call with(), as in date.with({ month: 3 }), which also returns a new value. Temporal exposes no setters at all.

solid answer

~50 s

Every Temporal type is an immutable value object, so `date.add({ days: 1 })` returns a new `Temporal.PlainDate` and the receiver is unchanged — if you ignore the return value, nothing happened. There are no setters anywhere in the API; to change a single field you call `date.with({ month: 3 })`, which is the copy-with-overrides operation and again returns a new object. The practical payoff is that passing a Temporal value into a function is safe: the callee cannot mutate your value out from under you, and a value stored as a Map key or in a cache stays correct. The trap that comes with it is comparison — because these are objects, `===` compares identity and is essentially always false, and Temporal deliberately makes `valueOf()` throw a TypeError so that `a < b` fails loudly instead of silently comparing strings. Use `a.equals(b)` and `Temporal.PlainDate.compare(a, b)` instead.

code

javascript · 15 lines
javascript
import { Temporal } from '@js-temporal/polyfill';

const d = Temporal.PlainDate.from('2026-01-31');

console.log(d.add({ days: 1 }).toString()); // 2026-02-01
console.log(d.toString());                  // 2026-01-31 (original untouched)

console.log(d.with({ month: 3 }).toString()); // 2026-03-31 (replace a field)
console.log(d.add({ months: 1 }).toString()); // 2026-02-28 (clamped by default)

try {
  d.add({ months: 1 }, { overflow: 'reject' });
} catch (e) {
  console.log(e.constructor.name); // RangeError
}

go deeper

for a junior

Remember that every Temporal method returns a new value and changes nothing — if you drop the return value you have done nothing. Use with() to change a field and equals() rather than === to compare.

for a middle

Explain why there are no setters at all, what the overflow option does when a computed day does not exist, and why compare() rather than a relational operator is the correct comparator for sorting.

for a senior

Articulate what immutability buys a codebase — no defensive copies, safe sharing across module boundaries, stable cache keys — and why a deliberately throwing valueOf converts a whole class of silent wrong answers into immediate failures.

for a principal

Be ready to defend value-object semantics as an API design position, including its allocation cost in hot paths, and to say where you would drop to exact-time arithmetic instead of abandoning the discipline across the codebase.

## What immutability means here A Temporal value behaves like a number: operations produce new values rather than editing the one you have. `add`, `subtract`, `with`, `withCalendar`, `round` and the conversion methods all return fresh objects, and the receiver is never touched. ```js const d = Temporal.PlainDate.from('2026-01-31'); const next = d.add({ days: 1 }); d.toString(); // '2026-01-31' — unchanged next.toString(); // '2026-02-01' ``` The single most common beginner error is treating these methods as commands: writing `d.add({ days: 1 });` on its own line and expecting `d` to move. It does not. If the returned value is discarded, the computation is discarded. This is a deliberate reversal of the legacy `Date` type, whose setter methods mutate in place, so that any function you hand a `Date` to can silently change it for every other holder of that reference. Temporal removes that entire failure mode by construction: there is nothing to mutate. ## Changing one field: with() Since there are no setters, the copy-with-overrides operation is `with()`. It takes an object of the fields you want replaced and returns a new value with everything else carried over: ```js const d = Temporal.PlainDate.from('2026-01-31'); d.with({ month: 3 }).toString(); // '2026-03-31' d.with({ year: 2030 }).toString(); // '2030-01-31' ``` `with()` replaces fields; `add()`/`subtract()` move by a duration. Confusing the two produces plausible-looking wrong answers, so say which you mean. ## Where results have to be clamped Because dates are real calendar values, some field combinations do not exist. Temporal handles this with an `overflow` option that defaults to `'constrain'`: ```js Temporal.PlainDate.from('2026-01-31').add({ months: 1 }).toString(); // '2026-02-28' — February has no 31st, so the day is clamped Temporal.PlainDate.from('2026-01-31').add({ months: 1 }, { overflow: 'reject' }); // RangeError ``` The same option applies to `from()` and `with()`. `'constrain'` is the forgiving default that most calendar UIs want; `'reject'` is what you pass when a silently clamped date would be a data-integrity bug. Note that clamping is not reversible: adding a month and subtracting it again does not always return you to the original day, which is a property of calendars rather than a defect in the library. ## The comparison consequence Immutable value semantics do not make these primitives. They are ordinary objects, so: - `a === b` compares references. Two Temporal values representing the same date are almost always different objects, so `===` returns `false`. It is not a bug you can work around; it is the wrong tool. - `a.equals(b)` is the value comparison. It exists on every Temporal type and compares the fields that matter, including the calendar. - `Temporal.PlainDate.compare(a, b)` is the ordering comparison, returning `-1`, `0` or `1`, which makes it a drop-in comparator for `Array.prototype.sort`. Temporal goes one step further than most libraries here: `valueOf()` on every Temporal type throws a `TypeError`. That is a design decision, not an oversight. Relational operators such as `a < b` and arithmetic such as `a - b` call `valueOf()` under the hood, and if it returned a number those expressions would appear to work while quietly ignoring calendars and time zones. Making it throw converts a silent wrong answer into an immediate, obvious failure at the exact line responsible. ```js const a = Temporal.PlainDate.from('2026-01-01'); const b = Temporal.PlainDate.from('2026-01-02'); a === b; // false — reference comparison a.equals(b); // false — value comparison Temporal.PlainDate.compare(a, b); // -1 // a < b // TypeError: valueOf throws by design [b, a].sort(Temporal.PlainDate.compare).map(String); // ['2026-01-01','2026-01-02'] ``` String interpolation still works, because that path uses `toString()` with a string hint rather than `valueOf()`: `` `${a}` `` yields `'2026-01-01'`. ## Practical consequences Immutability makes a few everyday patterns safe that were previously hazardous. You can hold one shared "today" value and pass it everywhere without defensive copies. You can chain confidently — `date.with({ day: 1 }).add({ months: 1 }).subtract({ days: 1 })` reads as "last day of this month" and creates three intermediate values, none of which can corrupt each other. And a value used as a cache key stays stable, because nobody can reach in and change it. The cost is allocation: chained calendar arithmetic in a hot loop creates objects. In practice this is rarely the bottleneck, and the fix — computing in `epochNanoseconds` or exact-time units where the calendar genuinely does not matter — is a targeted optimisation, not a reason to abandon the value types.

  • Why does Temporal make valueOf() throw instead of returning an epoch number?
    Because if it returned a number, `a < b` and `a - b` would appear to work while ignoring calendars and time zones, producing silently wrong results in exactly the cases people get wrong. Throwing a TypeError turns that into a loud failure on the offending line, and pushes you to `compare()` and `until()`, which handle those dimensions correctly.
  • What is the difference between with() and add() on a Temporal.PlainDate?
    `with()` replaces named fields and carries the rest over — `date.with({ month: 3 })` sets the month to March. `add()` moves by a duration — `date.add({ months: 1 })` advances one month from wherever you are, crossing year boundaries and clamping impossible days. They coincide only by accident, so choose deliberately.
  • Why does adding one month to 2026-01-31 and then subtracting one month not return 2026-01-31?
    Adding a month clamps to 2026-02-28 because February has no 31st, and subtracting a month from the 28th gives 2026-01-28. Calendar arithmetic is not reversible in general. If a silently clamped date would be a data bug, pass `{ overflow: 'reject' }` so the impossible date throws a RangeError instead.

saying these in an interview costs you the question

  • Calling date.add() mutates the date the way setMonth did
  • Using === to check whether two Temporal values are the same date
  • Sorting Temporal values with an a < b comparator
  • Assuming with() and add() do the same thing
  • Expecting add then subtract of a month to round-trip exactly

context