Functional Weave
Code in TypeScript

dates.add-months

Add calendar months to an ISO date, clamping to the month end: 31 Jan + 1 month is 28 or 29 Feb.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 22 tests, run in TypeScript, Python and Rust.

What it does

Moves the month and keeps the day of the month, and when that day does not exist in the target month it clamps to the month's last day: 31 January plus one month is 28 February (29 February in a leap year), 31 March minus one month is 28 February, and 29 February 2024 plus twelve months is 28 February 2025. This is the convention of spreadsheet EDATE, of Python's dateutil relativedelta and of most loan and subscription contracts. It is not what JavaScript's setMonth does: that rolls 31 January into 3 March.

Clamping is applied to the answer only, never carried forward. Always add months to the original anchor date: 31 January plus 2 months is 31 March, but (31 January plus 1) plus 1 is 28 March, because the first step already lost the 31st. A billing schedule should compute each date as anchor + k months (dates.recurrence does this).

For example

  • addMonths(2026-01-15, 1) → 2026-02-15 an ordinary mid-month date keeps its day
  • addMonths(2026-01-31, 1) → 2026-02-28 31 January plus one month clamps to 28 February, not 3 March
  • addMonths(2024-01-31, 1) → 2024-02-29 31 January plus one month in a leap year is 29 February

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function addMonths(iso: string, months: number): string
isodateISO date, YYYY-MM-DD
monthsintwhole months to add; negative moves backwards
returnsdate

Your code names it in one line, in the file that uses it

import { addMonths } from "#fune/dates.add-months@^1";
impl/typescript.ts · 25 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { daysInMonth, formatIsoDate, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune

/**
 * Add whole months, keeping the day of the month and clamping it to the last
 * day of the target month when that month is shorter (31 Jan + 1 = 28/29 Feb).
 *
 * The clamp is applied to the result only. Callers stepping through a schedule
 * must add k months to the original anchor, not 1 month k times, or the 31st
 * decays to the 28th after February.
 */
export function addMonths(iso: string, months: number): string {
  if (!Number.isInteger(months)) {
    throw new TypeError(`months must be an integer, received ${months}`);
  }
  const date = parseIsoDate(iso);
  // Count months from year 0 so the year and month fall out of one division.
  const total = date.year * 12 + (date.month - 1) + months;
  const year = Math.floor(total / 12);
  const month = total - year * 12 + 1;
  if (year < 1 || year > 9999) {
    throw new RangeError(`"${iso}" plus ${months} months is outside the supported range 0001-01-01 to 9999-12-31`);
  }
  const day = Math.min(date.day, daysInMonth(year, month));
  return formatIsoDate({ year, month, day });
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add dates.add-months
Download for TypeScript dates.add-months-1.0.0-typescript.fune · 6,666 bytes sha256 f6bd2f8e8ef5e28fbf6274db988e03b1edc8c8cea1d3893878404f234260754e

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./dates.add-months-1.0.0-typescript.fune, or fetch it from a terminal with fune pull dates.add-months@1.0.0:typescript.

The whole function, every language, is one file too: dates.add-months-1.0.0.fune, 9,622 bytes, sha256 365179042efaf5baa5a372bd6f600a4866198ee24bd38041b598e441b535513d. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before dates.add-months

after — your function gets the result and the arguments, and returns the final result.

// fune: after dates.add-months

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace dates.add-days in dates.add-months

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show dates.add-months --steps.

// fune: step dates.add-months after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
an ordinary mid-month date keeps its day 2026-01-15, 1 → 2026-02-15
31 January plus one month clamps to 28 February, not 3 March 2026-01-31, 1 → 2026-02-28
31 January plus one month in a leap year is 29 February 2024-01-31, 1 → 2024-02-29
31 January plus two months is 31 March: the clamp is not carried forward 2026-01-31, 2 → 2026-03-31
29 February plus twelve months is 28 February 2024-02-29, 12 → 2025-02-28
29 February plus forty-eight months lands on a leap day again 2024-02-29, 48 → 2028-02-29
31 March minus one month clamps to 28 February 2026-03-31, -1 → 2026-02-28
31 May plus one month clamps to 30 June 2026-05-31, 1 → 2026-06-30
30 April plus one month is 30 May, not the month end 2026-04-30, 1 → 2026-05-30
30 November plus three months crosses the year and clamps 2026-11-30, 3 → 2027-02-28
Show the other 12 tests
CaseArgumentsExpected
December plus one month rolls into January of the next year 2026-12-15, 1 → 2027-01-15
January minus one month rolls back into December 2026-01-15, -1 → 2025-12-15
minus thirteen months crosses two year boundaries 2026-01-31, -13 → 2024-12-31
zero months is the same date 2026-09-22, 0 → 2026-09-22
1900 was not a leap year, so 31 January 1900 plus one is 28 February 1900-01-31, 1 → 1900-02-28
2000 was a leap year 2000-01-31, 1 → 2000-02-29
a long mortgage term: 25 years of months 2026-08-31, 300 → 2051-08-31
the last supported month 9999-11-30, 1 → 9999-12-30
past 9999 is an error 9999-12-01, 1 → error: outside the supported range
before year 1 is an error 0001-01-15, -1 → error: outside the supported range
a fractional month count is an error 2026-01-31, 1.5 → error: months must be an integer
an impossible date is an error, not rolled forward 2026-02-30, 1 → error: is not a real calendar date

More from the author

Clamping is not "end of month stays end of month": 30 April plus one month is 30 May, not 31 May. If you mean the last day of the month, add months and then ask dates.month-boundaries for its end.

Results outside 0001-01-01 to 9999-12-31 are an error, as are impossible input dates (2026-02-30), which the dates.add-days kernel refuses to parse.

Files

PathBytes
README.md1,215
impl/python.py1,170
impl/rust.rs1,682
impl/typescript.ts1,135
vectors.json2,613