Add Months to a Date Without Overflow Using Temporal
To add months without month-end rollover, use Temporal.PlainDate.add({ months }), which defaults to overflow: 'constrain' and clamps January 31 + 1 month to the last day of February instead of spilling into March. Part of Immutable Date Arithmetic in JavaScript.
Why month addition is tricky
The legacy Date.setMonth() resolves an impossible calendar date by rolling forward. There is no February 31, so setMonth on January 31 lands in early March — March 2 or 3 depending on the leap year. For a subscription that bills on the 31st, that means the customer's "monthly" charge silently drifts a couple of days into the next month, every short month. Worse, setMonth mutates the original Date in place, so any other code holding that reference now sees the wrong value too.
The underlying ambiguity is real: "one month after January 31" has no single correct answer. A billing system usually wants "the last day of the next month" (clamp). A contract system might want to reject the operation and force a human or an upstream rule to decide. Legacy Date makes that choice for you — always rolling over — and offers no way to override it. Temporal exposes the decision as an explicit overflow option and never mutates the input.
The two overflow policies
This diagram shows the same January 31 + 1 month input resolving three different ways: legacy rollover, Temporal 'constrain', and Temporal 'reject'.
Minimal working solution
import { Temporal } from '@js-temporal/polyfill';
const jan31 = Temporal.PlainDate.from('2024-01-31');
// Default overflow is 'constrain': clamps to the last valid day of Feb.
const next = jan31.add({ months: 1 });
console.log(next.toString()); // '2024-02-29' (leap year), not March
console.log(jan31.toString()); // '2024-01-31' — input untouched
Full production version
For recurring billing, wrap the choice in a typed utility. Use PlainDate because only the calendar date matters — no timezone, no DST.
import { Temporal } from '@js-temporal/polyfill';
type Overflow = 'constrain' | 'reject';
export function getNextBillingDate(
current: string | Temporal.PlainDate,
monthsToAdd: number,
overflow: Overflow = 'constrain'
): Temporal.PlainDate {
if (!Number.isInteger(monthsToAdd)) {
throw new TypeError('monthsToAdd must be an integer'); // reject fractional months early
}
// PlainDate.from throws on malformed strings — invalid input fails loud.
const date =
typeof current === 'string' ? Temporal.PlainDate.from(current) : current;
// overflow: 'constrain' clamps month-ends; 'reject' throws on an invalid day.
return date.add({ months: monthsToAdd }, { overflow });
}
console.log(getNextBillingDate('2023-08-31', 6).toString()); // '2024-02-29' (clamped)
console.log(getNextBillingDate('2023-08-31', 7).toString()); // '2024-03-31' (valid, no clamp)
When you also need to display the result, format it through Intl rather than converting back to a mutable Date for arithmetic:
import { Temporal } from '@js-temporal/polyfill';
const next = getNextBillingDate('2023-08-31', 6); // '2024-02-29'
// Build the Date at UTC midnight purely for formatting — no further math on it.
const asDate = new Date(Date.UTC(next.year, next.month - 1, next.day));
const fmt = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' });
console.log(fmt.format(asDate)); // 'February 29, 2024'
If the result span itself needs to be added, scaled, or compared as a value, model it with Temporal.Duration — see Temporal duration arithmetic for how calendar units balance.
Verification
import { Temporal } from '@js-temporal/polyfill';
// Constrain clamps the leap-year month-end.
console.assert(
Temporal.PlainDate.from('2024-01-31').add({ months: 1 }).toString() === '2024-02-29'
);
// Constrain clamps the non-leap month-end.
console.assert(
Temporal.PlainDate.from('2023-01-31').add({ months: 1 }).toString() === '2023-02-28'
);
// Reject throws instead of clamping.
let threw = false;
try {
Temporal.PlainDate.from('2023-01-31').add({ months: 1 }, { overflow: 'reject' });
} catch {
threw = true; // RangeError: day 31 is out of range for 2023-02
}
console.assert(threw, 'reject must throw on an invalid month-end');
Common pitfalls
- Relying on legacy rollover.
new Date(2024,0,31).setMonth(1)yields March 2 and mutates the object.- Right:
Temporal.PlainDate.from('2024-01-31').add({ months: 1 })→2024-02-29, input untouched.
- Right:
- Using
ZonedDateTimefor pure calendar math. It drags in DST disambiguation you do not need.- Right: use
PlainDatewhen no timezone is involved; reach forZonedDateTimeonly for zoned scheduling.
- Right: use
- Calling
add({ months })on anInstant.Temporal.Instanthas no calendar, so this throws.- Right: convert to
ZonedDateTimefirst, e.g.instant.toZonedDateTimeISO('UTC').add({ months: 1 }).
- Right: convert to
- Assuming
'constrain'is universal. It is the default forPlainDate.add, but always passoverflowexplicitly in billing code so the intent is reviewable.
Frequently Asked Questions
What happens if I add one month to January 31st with Temporal?
By default (overflow: 'constrain'), the result clamps to February 29 in a leap year or February 28 otherwise, rather than rolling over to March the way legacy Date.setMonth() does. Pass overflow: 'reject' to throw a RangeError instead of clamping.
Does adding months with Temporal respect Daylight Saving Time?
For plain calendar dates with PlainDate there is no DST involved. If you add months to a Temporal.ZonedDateTime, the local wall-clock time is preserved and the UTC offset adjusts automatically; a result landing in a DST gap is resolved by the default 'compatible' disambiguation. See Immutable Date Arithmetic in JavaScript for the calendar-vs-absolute distinction.
Can I use this in browsers without native Temporal?
Yes. Install @js-temporal/polyfill (npm install @js-temporal/polyfill), pin the version, and import Temporal from it. Keep the arithmetic inside a utility function so switching to native Temporal later is trivial.