dates.month-boundaries
The first day, last day and length of the calendar month containing a date.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
Returns start, end and days together because callers need them together: a billing period, a statement range, a pro-rata fraction whose denominator is the length of the month. Handing back only the start and letting the caller derive the end is how "31 January plus one month" becomes 3 March.
"end" is the last day of the month, inclusive - 2024-02-29, not 2024-03-01. Inclusive is what a statement prints and what a human checks the figures against. Callers doing half-open range comparisons should add a day with dates.add-days rather than expecting this to hand back the 1st of the next month.
For example
monthBoundaries(2024-02-15)→ start 2024-02-01, end 2024-02-29, days 29 February 2024 has 29 days because 2024 is a leap yearmonthBoundaries(2024-02-29)→ start 2024-02-01, end 2024-02-29, days 29 the leap day itself resolves to the same monthmonthBoundaries(1900-02-15)→ start 1900-02-01, end 1900-02-28, days 28 February 1900 had 28 days: a century year is not a leap year
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 monthBoundaries(iso: string): MonthBoundaries
| iso | date | any date inside the month you want |
| returns | MonthBoundaries |
The type it declares, generated into your project
export interface MonthBoundaries {
/** the 1st of the month */
readonly start: string;
/** the last day of the month, inclusive: 28, 29, 30 or 31 */
readonly end: string;
/** how many days the month has */
readonly days: number;
}
Your code names it in one line, in the file that uses it
import { monthBoundaries } from "#fune/dates.month-boundaries@^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
import { type MonthBoundaries } from "./dates_month_boundaries_types.ts";
/**
* The first and last day of the month containing a date, and its length.
*
* Returns all three together because a caller needs them together - a billing
* period, a statement range, a pro-rata fraction - and because deriving `end`
* from `start` in caller code is where "31 January plus one month" bugs come
* from.
*
* `end` is the last day of the month, inclusive, not the 1st of the next
* month. Inclusive is what a statement prints and what a human checks against.
*/
export function monthBoundaries(iso: string): MonthBoundaries {
const date = parseIsoDate(iso);
// February is the only month whose length is not a constant, and it follows
// the full Gregorian rule: 2024 had 29 days, 1900 had 28 (a century year),
// 2000 had 29 (divisible by 400).
const days = daysInMonth(date.year, date.month);
return {
start: formatIsoDate({ year: date.year, month: date.month, day: 1 }),
end: formatIsoDate({ year: date.year, month: date.month, day: days }),
days,
};
}
/** True when the date is the last day of its month - a common billing trigger. */
export function isMonthEnd(iso: string): boolean {
const date = parseIsoDate(iso);
return date.day === daysInMonth(date.year, date.month);
}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.month-boundaries
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./dates.month-boundaries-1.0.0-typescript.fune, or fetch it from a terminal with fune pull dates.month-boundaries@1.0.0:typescript.
The whole function, every language, is one file too: dates.month-boundaries-1.0.0.fune, 10,722 bytes, sha256 9d24e00cd40e79db6edcafbb0f672a03ca0901aaa9c0c59efcc7c89f63270ee8. 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.month-boundaries
after — your function gets the result and the arguments, and returns the final result.
// fune: after dates.month-boundaries
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.month-boundaries
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.month-boundaries --steps.
// fune: step dates.month-boundaries 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 | |
|---|---|---|---|
| February 2024 has 29 days because 2024 is a leap year | 2024-02-15 | → | start 2024-02-01, end 2024-02-29, days 29 |
| the leap day itself resolves to the same month | 2024-02-29 | → | start 2024-02-01, end 2024-02-29, days 29 |
| February 1900 had 28 days: a century year is not a leap year | 1900-02-15 | → | start 1900-02-01, end 1900-02-28, days 28 |
| February 2000 had 29 days: divisible by 400, so it was | 2000-02-15 | → | start 2000-02-01, end 2000-02-29, days 29 |
| February 2100 will have 28 days, the next century exception | 2100-02-15 | → | start 2100-02-01, end 2100-02-28, days 28 |
| February 2026 is an ordinary 28 days | 2026-02-15 | → | start 2026-02-01, end 2026-02-28, days 28 |
| January has 31 days | 2026-01-15 | → | start 2026-01-01, end 2026-01-31, days 31 |
| April has 30 days | 2026-04-15 | → | start 2026-04-01, end 2026-04-30, days 30 |
| the 1st of a month is its own start | 2026-09-01 | → | start 2026-09-01, end 2026-09-30, days 30 |
| the last day of a month is its own end | 2026-09-30 | → | start 2026-09-01, end 2026-09-30, days 30 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| December ends the year on the 31st, not on 1 January | 2026-12-25 | → | start 2026-12-01, end 2026-12-31, days 31 |
| December 1900, well before the 1970 epoch | 1900-12-31 | → | start 1900-12-01, end 1900-12-31, days 31 |
| the first supported month | 0001-01-01 | → | start 0001-01-01, end 0001-01-31, days 31 |
| the last supported month | 9999-12-31 | → | start 9999-12-01, end 9999-12-31, days 31 |
| a slash-separated date is an error, not a guess | 15/02/2024 | → | error: is not an ISO date |
| a year-month with no day is an error | 2026-02 | → | error: is not an ISO date |
| 29 February 1900 never existed | 1900-02-29 | → | error: is not a real calendar date |
| 31 September never exists | 2026-09-31 | → | error: is not a real calendar date |
| month 13 is rejected | 2026-13-01 | → | error: is not a real calendar date |
More from the author
February is the only month whose length is not a constant, and it follows the full Gregorian rule rather than year % 4: 2024 had 29 days, 1900 had 28 because a century year is skipped, 2000 had 29 because every 400th is not. Those three cases are in the vectors precisely because the shortcut rule gets two of them wrong, and 1900 is still inside the range of real data - pension and land records reach back that far.
Files
| Path | Bytes |
|---|---|
| README.md | 1,044 |
| impl/python.py | 1,382 |
| impl/rust.rs | 1,901 |
| impl/typescript.ts | 1,391 |
| vectors.json | 2,707 |