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 dayaddMonths(2026-01-31, 1)→ 2026-02-28 31 January plus one month clamps to 28 February, not 3 MarchaddMonths(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
| iso | date | ISO date, YYYY-MM-DD |
| months | int | whole months to add; negative moves backwards |
| returns | date |
Your code names it in one line, in the file that uses it
import { addMonths } from "#fune/dates.add-months@^1";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,215 |
| impl/python.py | 1,170 |
| impl/rust.rs | 1,682 |
| impl/typescript.ts | 1,135 |
| vectors.json | 2,613 |