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.
pub fn paginate<T>(items: &[T], page: i64, per_page: i64) -> Page<T>
| items | T[] | the whole collection, already filtered and sorted |
| page | int | 1-based page number; 1 is the first page |
| per_page | 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
pub items: Vec<T>,
pub page: i64,
pub per_page: i64,
pub total: i64,
pub total_pages: i64,
pub has_next: bool,
pub has_prev: bool,
}
Your code names it in one line, in the file that uses it
fune!(collections.paginate@^1); // then call paginate(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// Take the 1-based page `page` of `items`, `per_page` 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 panics.
///
/// 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.
///
/// Generic over the item type, like the TypeScript and Python versions: a page
/// of `Invoice` is a `Page<Invoice>`, and a page of open JSON records is a
/// `Page<Value>`.
///
/// # Panics
/// Panics if `per_page` is less than 1 or `page` is less than 1.
pub fn paginate<T: Clone>(items: &[T], page: i64, per_page: i64) -> Page<T> {
// 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 per_page < 1 {
panic!("per_page must be at least 1, received {}", per_page);
}
if page < 1 {
panic!("page must be 1 or greater, received {}", page);
}
let total = items.len() as i64;
// Zero items is zero pages, not one empty page, so `page <= total_pages` is
// the one correct test for "does this page exist".
let total_pages = (total + per_page - 1) / per_page;
// Clamp rather than index: a start past the end is the documented
// empty-page case, not a slice panic.
let start = ((page - 1) * per_page).min(total) as usize;
let end = (start as i64 + per_page).min(total) as usize;
Page {
items: items[start..end].to_vec(),
page,
per_page,
total,
total_pages,
has_next: page < total_pages,
// On page 9 of a 2-page collection there really are earlier pages.
has_prev: page > 1,
}
}
/// The 1-based page number a zero-based item index falls on.
///
/// # Panics
/// Panics if `index` is negative or `per_page` is less than 1.
pub fn page_of_index(index: i64, per_page: i64) -> i64 {
if index < 0 {
panic!("index must be a whole number of 0 or greater, received {}", index);
}
if per_page < 1 {
panic!("per_page must be at least 1, received {}", per_page);
}
index / per_page + 1
}
pub fn page_to_value(page: &Page<Value>) -> Value {
Value::obj(vec![
("items", Value::Arr(page.items.clone())),
("page", Value::Int(page.page)),
("perPage", Value::Int(page.per_page)),
("total", Value::Int(page.total)),
("totalPages", Value::Int(page.total_pages)),
("hasNext", Value::Bool(page.has_next)),
("hasPrev", Value::Bool(page.has_prev)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
// Refuse what the typed signature cannot hold, with the wording TypeScript
// and Python use, rather than let the conversion below quietly change it.
if let Value::Float(f) = args[1] {
if f.fract() != 0.0 {
panic!("page must be a whole number, received {}", f);
}
}
if let Value::Float(f) = args[2] {
if f.fract() != 0.0 {
panic!("perPage must be a whole number, received {}", f);
}
}
page_to_value(&paginate(
args[0].as_arr(),
args[1].as_i64(),
args[2].as_i64(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add collections.paginate
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./collections.paginate-1.0.0-rust.fune, or fetch it from a terminal with fune pull collections.paginate@1.0.0:rust.
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 |