Functional Weave
Code in TypeScript

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 days
  • businessDaysBetween(2026-09-16, 2026-09-16, ) → 0 the same date twice is zero: the interval is half-open
  • businessDaysBetween(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
startIsodateISO date, inclusive
endIsodateISO date, exclusive
holidaysdate[]non-working dates; order and duplicates do not matter
returnsintworking 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";
impl/typescript.ts · 50 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 { 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
Download for TypeScript dates.business-days-between-1.0.0-typescript.fune · 9,371 bytes sha256 9ae698be0352e85b93a2523d9e5316600d9dab54904521a81995c2cc24cfb60f

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,432
impl/python.py2,344
impl/rust.rs2,833
impl/typescript.ts2,383
vectors.json3,482