dates.business-days-between
Working days between two dates, excluding weekends and a caller-supplied holiday list.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
The interval is half-open: start inclusive, end exclusive. Two consequences a caller has to know. Same date twice is 0, not 1. And intervals compose: between(a,b) + between(b,c) = between(a,c), which is what lets a report be summed by week or by month without double counting the boundary days. If you want the end date included - "the job takes five working days and today counts" - pass the day after it.
A start later than the end returns a negative count, exactly -between(end, start). Clamping to zero would silently absorb an argument-order bug, and a payment term calculated from swapped dates should look obviously wrong rather than plausibly zero.
For example
businessDaysBetween(2026-09-14, 2026-09-21, )→ 5 a full Monday-to-Monday week is five working daysbusinessDaysBetween(2026-09-16, 2026-09-16, )→ 0 the same date twice is zero: the interval is half-openbusinessDaysBetween(2026-09-16, 2026-09-17, )→ 1 start is inclusive, so one working day spans one night
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 businessDaysBetween(startIso: string, endIso: string, holidays: readonly string[]): number
| startIso | date | ISO date, inclusive |
| endIso | date | ISO date, exclusive |
| holidays | date[] | non-working dates; order and duplicates do not matter |
| returns | int | working days in [start, end); negative when start is after end |
Your code names it in one line, in the file that uses it
import { businessDaysBetween } from "#fune/dates.business-days-between@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
/**
* Working days between two dates, excluding Saturdays, Sundays and any date in
* the holiday list.
*
* The interval is half-open: the start date counts, the end date does not.
* That is the convention that makes ranges compose - businessDaysBetween(a, b)
* plus businessDaysBetween(b, c) equals businessDaysBetween(a, c) - and it
* makes "how many working days until the deadline" come out at zero on the
* deadline itself rather than one. If you want the end date included, ask for
* the day after it.
*
* A start later than the end returns a negative count, and the magnitude is
* the same as the forward direction: businessDaysBetween(a, b) is exactly
* -businessDaysBetween(b, a). Returning zero or throwing would both hide a
* caller's argument-order bug.
*
* Holidays are an argument, not built in, because no library knows which days
* your company is closed. Weekend holidays are not double counted, duplicates
* in the list are harmless, and holidays outside the interval are ignored.
*/
export function businessDaysBetween(startIso: string, endIso: string, holidays: readonly string[] = []): number {
const start = epochDayFromIso(startIso);
const end = epochDayFromIso(endIso);
// Every holiday is validated even when it falls outside the interval: a typo
// in a holiday calendar should fail loudly on the next run, not lie dormant
// until the year the date is finally inside a query.
const excluded = new Set<number>();
for (const holiday of holidays) excluded.add(epochDayFromIso(holiday));
if (start > end) return -countWorkingDays(end, endIso, start, excluded);
return countWorkingDays(start, startIso, end, excluded);
}
/** Working days in [from, to), given the weekday of `from` as an ISO date. */
function countWorkingDays(from: number, fromIso: string, to: number, excluded: Set<number>): number {
// The weekday is carried forward rather than recomputed per day: one date
// parse, then a seven-day cycle, which is the same loop in all three
// languages and cannot drift between them.
let weekday = dayOfWeek(fromIso);
let count = 0;
for (let day = from; day < to; day++) {
if (weekday <= 5 && !excluded.has(day)) count++;
weekday = weekday === 7 ? 1 : weekday + 1;
}
return count;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, 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.business-days-between
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./dates.business-days-between-1.0.0-typescript.fune, or fetch it from a terminal with fune pull dates.business-days-between@1.0.0:typescript.
The whole function, every language, is one file too: dates.business-days-between-1.0.0.fune, 14,702 bytes, sha256 350624abbac7ca1f91a54f9cf79a8bb6f43b98eef82b4135be420f139c178a88. 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.business-days-between
after — your function gets the result and the arguments, and returns the final result.
// fune: after dates.business-days-between
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.business-days-between
// fune: replace dates.day-of-week in dates.business-days-between
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.business-days-between --steps.
// fune: step dates.business-days-between 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 | |
|---|---|---|---|
| a full Monday-to-Monday week is five working days | 2026-09-14, 2026-09-21, | → | 5 |
| the same date twice is zero: the interval is half-open | 2026-09-16, 2026-09-16, | → | 0 |
| start is inclusive, so one working day spans one night | 2026-09-16, 2026-09-17, | → | 1 |
| end is exclusive: Monday to Tuesday counts Monday only | 2026-09-14, 2026-09-15, | → | 1 |
| the first half of the week composes with the second | 2026-09-14, 2026-09-16, | → | 2 |
| and the second half: 2 plus 3 is the 5 of the whole week | 2026-09-16, 2026-09-21, | → | 3 |
| a weekend on its own has no working days | 2026-09-19, 2026-09-21, | → | 0 |
| Friday to Tuesday crosses a weekend and counts two days | 2026-09-18, 2026-09-22, | → | 2 |
| two holidays inside a Christmas week | 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-28 | → | 1 |
| the same holiday listed twice is only subtracted once | 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-25 | → | 2 |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a holiday falling on a Saturday is not subtracted twice | 2026-08-28, 2026-09-01, 2026-08-29 | → | 2 |
| a holiday outside the interval is ignored | 2026-09-14, 2026-09-16, 2026-12-25 | → | 2 |
| a holiday on the start date removes the only working day | 2026-09-14, 2026-09-15, 2026-09-14 | → | 0 |
| Good Friday and Easter Monday around a weekend | 2026-04-02, 2026-04-07, 2026-04-03, 2026-04-06 | → | 1 |
| across the new year with New Year's Day as a holiday | 2026-12-28, 2027-01-04, 2027-01-01 | → | 4 |
| the 2024 leap day is an ordinary working Thursday | 2024-02-28, 2024-03-01, | → | 2 |
| 2000 had a leap day, so the same span is still two working days | 2000-02-28, 2000-03-01, | → | 2 |
| 1900 had no leap day and the span is two working days from the 27th | 1900-02-27, 1900-03-01, | → | 2 |
| a month-long span | 2026-09-14, 2026-10-14, | → | 22 |
| a reversed interval is negative, not zero | 2026-09-21, 2026-09-14, | → | -5 |
| a reversed interval respects holidays with the same magnitude | 2026-09-21, 2026-09-14, 2026-09-16 | → | -4 |
| a malformed start date is an error | 14/09/2026, 2026-09-21, | → | error: is not an ISO date |
| a malformed end date is an error | 2026-09-14, 2026-09, | → | error: is not an ISO date |
| an impossible end date is an error, not a rolled-forward guess | 2026-09-14, 2026-02-30, | → | error: is not a real calendar date |
| a malformed holiday is an error even though it is outside the interval | 2026-09-14, 2026-09-21, 25/12/2026 | → | error: is not an ISO date |
| an impossible holiday is an error | 2026-09-14, 2026-09-21, 2026-11-31 | → | error: is not a real calendar date |
More from the author
Holidays are an argument because no library knows which days your business is closed, and a bank holiday calendar is jurisdiction-specific, year-specific and sometimes employer-specific. Holidays on a weekend are not subtracted twice, duplicates in the list are harmless, and holidays outside the interval are ignored - but they are still validated, so a typo in a calendar fails on the next run rather than in the year it finally falls inside a query.
The count is a day-by-day loop rather than a closed-form week count. It is O(days) and that is a deliberate trade: the loop is the same six lines in all three languages, which makes the parity gate meaningful, and the closed form is where off-by-one errors around the start weekday live.
Files
| Path | Bytes |
|---|---|
| README.md | 1,432 |
| impl/python.py | 2,344 |
| impl/rust.rs | 2,833 |
| impl/typescript.ts | 2,383 |
| vectors.json | 3,482 |