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

Adding a month is ambiguous whenever the starting day does not exist in the target month, and that ambiguity is not a rare edge β€” it hits every month-end. "One month after January 31" has no literal answer because February has no 31st, so an implementation must choose a resolution, and legacy JavaScript chooses the worst one silently. new Date(2024, 0, 31) then setMonth(1) does not give you the end of February; it overflows the extra days into March, yielding March 2 or 3. Nothing warns you, the value is a valid date, and a monthly billing job built on it quietly skips February for any customer whose anchor day is the 29th, 30th, or 31st.

Temporal reframes the operation as a decision rather than an accident. Because add is immutable it returns a new date and never corrupts the anchor, and because it takes an overflow option it makes you β€” or a sensible default β€” decide what a month-end should do. That turns a hidden landmine into an explicit, testable policy, which is exactly what date-sensitive financial and scheduling code needs.

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.

Not every month has 31 daysJan 31 + 1 month = ?Not every month has 31 dayssetMonth overflowJan31 β†’ Mar 3add + overflow policyJan31 β†’ Feb 29Adding a month to a month-end date must clamp, not spill into the next month.

The two overflow policies

constrain, the default, clamps an out-of-range day down to the last valid day of the target month: January 31 plus one month becomes February 29 in a leap year or February 28 otherwise. This is the right default for most user-facing scheduling, because it keeps the event in the intended month rather than spilling into the next one. reject instead throws a RangeError when the day does not exist, handing control back to you so you can decide β€” perhaps prompt the user, perhaps fall back to a business rule. Neither is universally correct; the point is that you pick one on purpose.

The choice interacts with how you sequence multiple additions. If you advance month by month and clamp at each step, the "31" is lost after the first clamp: January 31 β†’ February 28 β†’ March 28, because March's addition starts from the 28th. If instead you always compute from the original anchor β€” anchor plus N months β€” the clamp is re-evaluated each time and March correctly returns to the 31st. For recurring billing that should bill on "the 31st, or the last day if shorter," anchoring every occurrence to the original date is the behavior you want, and the immutable model makes that trivial because the anchor never changes.

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

Add with a policyadd({months:1}) with overflow:'constrain'Add with a policyJan 31add({months:1})constrainFeb 29

Full production version

A production helper does more than call add. It validates that the month count is an integer, because a fractional month is meaningless and should fail loudly rather than be silently truncated. It parses string input through Temporal.PlainDate.from, which throws on malformed data, so bad input is caught at the boundary instead of propagating as a wrong date. And it takes the overflow policy as a parameter with a sensible default, so the same function serves both the "clamp to month-end" and the "reject invalid" callers without duplication. The result is a single, well-tested choke point for every month advance in the system.

This matters most for billing and subscription logic, where the anchor day and the overflow policy together define real money movement. A subscription that started on August 31 and renews every month must decide, once and consistently, whether its February charge lands on the 28th/29th or is handled some other way β€” and it must apply that decision identically across every renewal, in every time zone, for every customer. Because PlainDate is zoneless, the billing date a server computes in UTC matches the one a customer sees in their own zone, removing an entire class of "charged a day early" disputes. The helper encapsulates all of that behind one call.

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.

Validated month addChoose constrain vs reject explicitly, never mutateValidated month adddate + nvalidateadd + policynew date

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');

Month-add assertionsOverflow policy behaves as chosenAssertions that prove the edge caseJan31 +1mo constrainFeb 29Jan31 +1mo rejectthrowsMar31 +1moApr 30leap vs common28/29

Common pitfalls

The headline pitfall is the legacy overflow itself: using setMonth or new Date arithmetic on a month-end and getting a value in the next month without noticing. The second is the sequencing trap β€” clamping at each step of a monthly loop and permanently losing the anchor day β€” which produces a schedule that drifts earlier over time. Re-derive each occurrence from the original anchor to avoid it. The third is forgetting that add is immutable and expecting it to mutate the anchor in place; code ported from legacy Date sometimes ignores the return value, which with Temporal means the computation is simply thrown away.

A subtler mistake is mixing up the semantics of { months: 1 } versus { days: 30 }. Adding a month is a calendar operation whose day-count varies (28 to 31 days); adding 30 days is a fixed step that only coincidentally resembles a month. Reaching for a fixed day count to approximate a month is how schedules slowly desynchronize from the calendar. Finally, if your value is a ZonedDateTime rather than a PlainDate, remember that month addition there also respects DST, so a "same day next month" result keeps its wall-clock time across a transition β€” usually what you want, but worth asserting in a test.

Month-add pitfallsMutation and silent overflowWrongRightsetMonth(getMonth()+1)Jan31β†’Mar 3add({months:1}) immutablecopy returnedignore overflow optionsurprising dayset overflow explicitlyclamp or throw

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.

What happens when I add a month to January 31 in Temporal?

With the default overflow of 'constrain', jan31.add({ months: 1 }) clamps to the last valid day of February β€” February 29 in a leap year, otherwise February 28 β€” and leaves the original date untouched because Temporal values are immutable. With overflow 'reject' it throws a RangeError instead, so you can handle the non-existent day explicitly. Legacy Date, by contrast, silently overflows the extra days into early March.

How do I keep a monthly schedule from drifting off the 31st?

Compute each occurrence from the original anchor rather than from the previous result. Anchor.add({ months: n }) re-evaluates the overflow clamp for each month, so March returns to the 31st even though February clamped to the 28th or 29th. If you instead add one month repeatedly to the last computed date, the first clamp permanently loses the 31 and the schedule drifts earlier. The immutable anchor makes the correct approach the natural one.