collections.paginate
Slice a list into a 1-based page and report the totals an API response needs.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Page numbers are 1-based, because every pagination UI, every query string and every API contract in the wild is 1-based. Page 0 is therefore a caller bug and raises.
A page past the end is NOT an error. It returns an empty items list with the real totals, because a client asking for page 9 of a collection that shrank to 2 pages between requests is a race, not a mistake, and it should render an empty page rather than a 500.
For example
paginate(items ×7, 1, 3)→ items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false first page of seven items, three at a timepaginate(items ×7, 2, 3)→ items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true a middle page has a page either side of itpaginate(items ×7, 3, 3)→ items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true the last page is short and has no next
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 paginate<T>(items: readonly T[], page: number, perPage: number): Page<T>
| items | T[] | the whole collection, already filtered and sorted |
| page | int | 1-based page number; 1 is the first page |
| perPage | int | page size, 1 or greater |
| returns | Page<T> |
The type it declares, generated into your project
/** One page of a collection, plus everything a client needs to draw a pager. */
export interface Page<T> {
readonly items: readonly T[];
readonly page: number;
readonly perPage: number;
readonly total: number;
readonly totalPages: number;
readonly hasNext: boolean;
readonly hasPrev: boolean;
}
Your code names it in one line, in the file that uses it
import { paginate } from "#fune/collections.paginate@^1";
import { type Page } from "./collections_paginate_types.ts";
/**
* Take the 1-based page `page` of `items`, `perPage` items at a time.
*
* Page numbers are 1-based because every pagination UI, query string and API
* contract in the wild is 1-based; page 0 can only come from a caller that has
* the contract wrong, so it raises.
*
* A page past the end is deliberately not an error. Between two requests a
* collection can shrink under a client that is already on page 9, and that
* client should render an empty page, not a 500.
*/
export function paginate<T>(items: readonly T[], page: number, perPage: number): Page<T> {
if (!Array.isArray(items)) {
throw new TypeError("paginate needs a list of items");
}
if (!Number.isInteger(page)) {
throw new TypeError(`page must be a whole number, received ${page}`);
}
if (!Number.isInteger(perPage)) {
throw new TypeError(`perPage must be a whole number, received ${perPage}`);
}
// A page size of zero cannot mean "no limit": that would quietly hand back
// the entire collection, which is the outage pagination exists to prevent.
if (perPage < 1) {
throw new RangeError(`perPage must be at least 1, received ${perPage}`);
}
if (page < 1) {
throw new RangeError(`page must be 1 or greater, received ${page}`);
}
const total = items.length;
// Zero items is zero pages, not one empty page, so `page <= totalPages` is
// the one correct test for "does this page exist".
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage;
return {
// slice() already clamps, so a start past the end yields [] rather than
// throwing - the documented behaviour for a page past the end.
items: items.slice(start, start + perPage) as T[],
page,
perPage,
total,
totalPages,
hasNext: page < totalPages,
// On page 9 of a 2-page collection there really are earlier pages.
hasPrev: page > 1,
};
}
/** The 1-based page number a zero-based item index falls on. */
export function pageOfIndex(index: number, perPage: number): number {
if (!Number.isInteger(index) || index < 0) {
throw new RangeError(`index must be a whole number of 0 or greater, received ${index}`);
}
if (!Number.isInteger(perPage) || perPage < 1) {
throw new RangeError(`perPage must be at least 1, received ${perPage}`);
}
return Math.floor(index / perPage) + 1;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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 collections.paginate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./collections.paginate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull collections.paginate@1.0.0:typescript.
The whole function, every language, is one file too: collections.paginate-1.0.0.fune, 17,341 bytes, sha256 8929c0b7700311f6c92da9535e16d1b59c1b2c009d632583ebe0718405baa03b. 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 collections.paginate
after — your function gets the result and the arguments, and returns the final result.
// fune: after collections.paginate
replace — it requires no other capability, so there is no dependency to replace.
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 collections.paginate --steps.
// fune: step collections.paginate 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 | |
|---|---|---|---|
| first page of seven items, three at a time | items ×7, 1, 3 | → | items ×3, page 1, per page 3, total 7, total pages 3, has next true, has prev false |
| a middle page has a page either side of it | items ×7, 2, 3 | → | items ×3, page 2, per page 3, total 7, total pages 3, has next true, has prev true |
| the last page is short and has no next | items ×7, 3, 3 | → | items ×1, page 3, per page 3, total 7, total pages 3, has next false, has prev true |
| a page past the end is empty, not an error, and still reports the real totals | items ×7, 9, 3 | → | items , page 9, per page 3, total 7, total pages 3, has next false, has prev true |
| the page immediately after the last one is empty | items ×7, 4, 3 | → | items , page 4, per page 3, total 7, total pages 3, has next false, has prev true |
| a page size that divides exactly leaves no short page | items ×6, 2, 3 | → | items ×3, page 2, per page 3, total 6, total pages 2, has next false, has prev true |
| one item per page makes as many pages as there are items | items ×7, 4, 1 | → | items ×1, page 4, per page 1, total 7, total pages 7, has next true, has prev true |
| a single item is a single full page with nothing either side | items ×1, 1, 10 | → | items ×1, page 1, per page 10, total 1, total pages 1, has next false, has prev false |
| an empty collection is zero pages, not one empty page | , 1, 10 | → | items , page 1, per page 10, total 0, total pages 0, has next false, has prev false |
| page two of an empty collection still says there is something behind it | , 2, 10 | → | items , page 2, per page 10, total 0, total pages 0, has next false, has prev true |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a page size bigger than the collection returns all of it | items ×2, 1, 50 | → | items ×2, page 1, per page 50, total 2, total pages 1, has next false, has prev false |
| records are passed through untouched, however uneven their shapes | items ×3, 1, 2 | → | items ×2, page 1, per page 2, total 3, total pages 2, has next true, has prev false |
| a page size of zero is an error, never the whole collection | items ×1, 1, 0 | → | error: must be at least 1 |
| a negative page size is an error | items ×1, 1, -5 | → | error: must be at least 1 |
| page zero is a caller bug because pages are 1-based | items ×1, 0, 10 | → | error: page must be 1 or greater |
| a negative page is a caller bug | items ×1, -2, 10 | → | error: page must be 1 or greater |
| a fractional page number is a caller bug | items ×1, 1.5, 10 | → | error: page must be a whole number |
| a fractional page size is a caller bug | items ×1, 1, 2.5 | → | error: must be a whole number |
More from the author
perPage of 0 or less is an error. There is no honest page of zero items: an unbounded page would silently return the whole collection, which is exactly the outage pagination exists to prevent. Ask for the whole list explicitly instead.
totalPages is 0 for an empty collection, not 1. Zero items really is zero pages, and it makes `page <= totalPages` the single correct test for whether a page exists.
hasPrev is `page > 1`, not `total > 0`: on page 9 of a 2-page collection there genuinely are earlier pages to go back to.
Files
| Path | Bytes |
|---|---|
| README.md | 979 |
| impl/python.py | 2,762 |
| impl/rust.rs | 3,422 |
| impl/typescript.ts | 2,415 |
| vectors.json | 4,580 |