~/blog

Temporal clamps February 30 to February 28 unless you ask it not to

published

#javascript#temporal#dates#node

TL;DR

Temporal’s default for out-of-range date fields is overflow: 'constrain'. A property bag of { year: 2026, month: 2, day: 30 } quietly becomes February 28, January 31, 2026 plus one month becomes February 28, and hour: 25 becomes 23:00 on the same day. Under the default, only ISO strings and the constructor throw for a day that does not exist. Pass { overflow: 'reject' } wherever the fields came from a user or another system, and compute recurring dates from the original anchor instead of chaining add().

The problem

Node.js 26 ships Temporal enabled by default, so code moving off Date lands on it without a flag or a polyfill. The habit people bring from Date is rollover: new Date(Date.UTC(2026, 1, 30)) is March 2. Temporal does not roll over, and for property bags and arithmetic it does not reject by default either. It clamps:

const fromForm = Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 });
console.log(fromForm.toString()); // 2026-02-28

const due = Temporal.PlainDate.from('2026-01-31').add({ months: 1 });
console.log(due.toString()); // 2026-02-28

No exception, no warning. If day: 30 came from a form, the user asked for a date that does not exist and your code stored a different one that does.

Why it happens

Temporal’s entry points disagree about what to do with a field that is out of range. Results below are from Node.js v26.2.0:

Entry pointOptionResult for an impossible date
Temporal.PlainDate.from({ year: 2026, month: 2, day: 30 })overflow, default 'constrain'2026-02-28
date.with({ day: 31 }) on a February dateoverflow, default 'constrain'last day of February
date.add({ months: 1 }) from January 31overflow, default 'constrain'2026-02-28
Temporal.PlainDateTime.from({ year: 2026, month: 4, day: 30, hour: 25 })overflow, default 'constrain'2026-04-30T23:00:00
Temporal.PlainDate.from('2026-02-30')overflow is ignoredRangeError
new Temporal.PlainDate(2026, 2, 30)noneRangeError

The proposal documentation states the string rule directly: “The overflow option is ignored if item is a string.” The constructor takes raw ISO numbers and throws for any date that does not exist. Property bags and arithmetic default to clamping. The docs explain why arithmetic needs the option at all: “adding one month to August 31 would result in September 31, which doesn’t exist.”

Two details make this easy to get wrong:

Clamping is lossy, so chains drift

Once a date has been clamped, the original day is gone:

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

const chained = anchor.add({ months: 1 }).add({ months: 1 });
console.log(chained.toString()); // 2026-03-28

const direct = anchor.add({ months: 2 });
console.log(direct.toString()); // 2026-03-31

const back = anchor.add({ months: 1 }).subtract({ months: 1 });
console.log(back.toString()); // 2026-01-28

console.log(anchor.until('2026-02-28', { largestUnit: 'months' }).toString()); // P28D

A monthly schedule written as next = previous.add({ months: 1 }) walks 31, 28, 28, 28 for the rest of its life. Going one month forward and one month back does not return to the start. And until() does not call the gap one month: from January 31 to February 28 is P28D, because a whole month from the 31st would end on a day February does not have.

What to do

1. Reject at the boundary. Anything that arrives as separate fields (a form, a JSON payload, a CSV row) should be parsed with overflow: 'reject':

function parseUserDate(fields) {
  return Temporal.PlainDate.from(fields, { overflow: 'reject' });
}

try {
  parseUserDate({ year: 2026, month: 2, day: 30 });
} catch (err) {
  console.log(err.name); // RangeError
}

On Node 26.2.0 the message is Temporal error: day value is not in a valid range. Message text varies between engines, so branch on RangeError, not on the string.

2. Keep clamping where it is the business rule, but always start from the anchor. “Bill on the 31st, or the last day of shorter months” is exactly what constrain does, as long as every occurrence is computed from the original date:

const anchor = Temporal.PlainDate.from('2026-01-31');
const schedule = Array.from({ length: 4 }, (_, i) => anchor.add({ months: i }).toString());
console.log(schedule); // [ '2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30' ]

Store the anchor and the index, not the last computed date.

3. Ask arithmetic to fail when it matters. add(), subtract() and with() accept the same option:

try {
  Temporal.PlainDate.from('2026-01-31').add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
  console.log(err.name); // RangeError
}

Use it where a silently moved date would be a bug rather than a convenience, such as a contract term or a “same day next month” reminder that must not fire early.

Caveats

References