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

Three resolutions of an invalid month-end dateLegacy setMonth overflows January 31 plus one month to March 2. Temporal with overflow constrain clamps to February 29 in a leap year. Temporal with overflow reject throws a RangeError.Jan 31 + 1 monthlegacy setMonthrolls forwardMar 2+ mutates input'constrain'clamp to month endFeb 29default, leap year'reject'refuse invalid dayRangeErrorcaller decidesoverflow controls the calendar date; the input PlainDate is never mutated

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

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.