Temporal clamps February 30 to February 28 unless you ask it not to
published
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 point | Option | Result 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 date | overflow, default 'constrain' | last day of February |
date.add({ months: 1 }) from January 31 | overflow, 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 ignored | RangeError |
new Temporal.PlainDate(2026, 2, 30) | none | RangeError |
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:
- Time fields clamp too, and they do not carry.
hour: 25is not “1 a.m. tomorrow”, it is 23:00 today. If you want the carry, do the arithmetic:Temporal.PlainDateTime.from('2026-04-30T23:00').add({ hours: 2 })is2026-05-01T01:00:00. - Do not lean on MDN’s string example. MDN’s
PlainDate.from()page currently showsTemporal.PlainDate.from("2021-02-29")printing"2021-02-28". The proposal docs sayoverflowdoes not apply to strings, and Node 26.2.0 throwsRangeError: Temporal error: Parsed day value not in a valid range.for exactly that kind of input. For an impossible date, a string is the one input you can count on to be rejected, not clamped. (Time strings are not quite as strict: a leap second:60is read as:59even withoverflow: 'reject'.)
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
- For
monthandday,overflowonly handles values that are too large. Zero or negative values throw either way:{ year: 2026, month: 2, day: 0 }throwsRangeError: Temporal error: Expected positive integer.even under the default. Years may be zero or negative, and time fields clamp in both directions (hour: -1becomes 00:00). - It does not cover time zones. Wall-clock times that fall in a DST gap or overlap are resolved by
ZonedDateTime’s separatedisambiguationoption, and an offset that conflicts with the zone by itsoffsetoption. - The error messages quoted here are Node.js v26.2.0’s. Other runtimes word them differently.
- Temporal is not yet Baseline in browsers; MDN marks it as limited availability. Front-end code may still be running on a polyfill, which should follow the same rules but is worth testing against the cases above.