collections.sort-by
Stably sort records by one key, ascending or descending, with a total order across mixed types.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript, Python and Rust.
What it does
The sort is stable: records that tie on the key come out in the order they went in, in BOTH directions. Descending negates the comparison rather than reversing the list, so the tied block is not silently flipped - that is what makes sorting by one column and then another compose into the multi-column sort a user expects.
The total order for mixed values is: booleans, then numbers, then strings, and absent values last. Absent values sink to the bottom in both directions, because 'the rows we know nothing about' belong at the end of a descending table as much as an ascending one; reversing them would put the empty rows first, which no one has ever wanted.
For example
sort_by(records ×3, n, asc)→ ×3 ascending by a string keysort_by(records ×3, n, desc)→ ×3 descending by a string keysort_by(records ×3, s, asc)→ ×3 ascending by a number key
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 sort_by(records: &[Value], key: &str, direction: &str) -> Vec<Value>
| records | record[] | open JSON-ish maps; shapes may differ between records |
| key | string | the field to order by |
| direction | SortDirection | |
| returns | record[] | a new list; the input is never reordered in place |
The type it declares, generated into your project
// SortDirection is a string in Rust, one of: "asc", "desc".
// Parameters take it as &str and results hold it as String.
Your code names it in one line, in the file that uses it
fune!(collections.sort-by@^1); // then call sort_by(…)
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
use std::cmp::Ordering;
// The type ranks that give mixed values a total order. Absent sorts last in
// both directions, so it is ranked above every present value and then excluded
// from the direction flip below.
const RANK_BOOL: u8 = 0;
const RANK_NUMBER: u8 = 1;
const RANK_STRING: u8 = 2;
const RANK_ABSENT: u8 = 3;
/// # Panics
/// Panics if the value at the key is a list or a map.
fn rank_of(value: &Value, key: &str) -> u8 {
match value {
// `Value::get` returns Null for a missing field, and a document that
// nulls a field means the same thing, so both rank as absent.
Value::Null => RANK_ABSENT,
Value::Bool(_) => RANK_BOOL,
Value::Int(_) | Value::Float(_) => RANK_NUMBER,
Value::Str(_) => RANK_STRING,
_ => panic!("cannot sort by the list or map at \"{}\"", key),
}
}
fn compare_scalars(a: &Value, b: &Value) -> Ordering {
match (a, b) {
// Integers compare as integers: going through f64 would collapse
// values above 2^53 that are genuinely different.
(Value::Int(x), Value::Int(y)) => x.cmp(y),
// Rust compares &str by UTF-8 bytes, which is Unicode code point order,
// the same order Python uses and the one TypeScript spells out by hand.
(Value::Str(x), Value::Str(y)) => x.cmp(y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
_ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal),
}
}
/// Stably sort `records` by `key`, ascending or descending.
///
/// Ties keep their input order in both directions, which is what lets a user
/// sort by one column and then another and get the multi-column sort they
/// expect rather than a reshuffle. `sort_by` is a stable sort, and the
/// comparator returns Equal for ties rather than falling back to position.
///
/// Records are `Value` because they are open JSON maps; a struct would be a
/// lie about data whose shape differs from one record to the next.
///
/// # Panics
/// Panics on an unknown direction, an empty key, or a list or map at the key.
pub fn sort_by(records: &[Value], key: &str, direction: &str) -> Vec<Value> {
if key.is_empty() {
panic!("sort_by needs a non-empty key name");
}
if direction != "asc" && direction != "desc" {
panic!("direction must be \"asc\" or \"desc\", received \"{}\"", direction);
}
// Rank every record up front. Panicking mid-comparison would make the
// failure depend on which comparisons the sort happened to perform.
let ranks: Vec<u8> = records.iter().map(|r| rank_of(r.get(key), key)).collect();
let descending = direction == "desc";
let mut order: Vec<usize> = (0..records.len()).collect();
order.sort_by(|&i, &j| {
let (ri, rj) = (ranks[i], ranks[j]);
// Absent values sink to the bottom whichever way the sort runs: nobody
// wants the rows they know nothing about at the top of a descending table.
if ri == RANK_ABSENT || rj == RANK_ABSENT {
return match (ri, rj) {
(RANK_ABSENT, RANK_ABSENT) => Ordering::Equal,
(RANK_ABSENT, _) => Ordering::Greater,
_ => Ordering::Less,
};
}
let base = if ri != rj {
ri.cmp(&rj)
} else {
compare_scalars(records[i].get(key), records[j].get(key))
};
if descending {
base.reverse()
} else {
base
}
});
order.into_iter().map(|i| records[i].clone()).collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Arr(sort_by(args[0].as_arr(), args[1].as_str(), args[2].as_str()))
}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.sort-by
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./collections.sort-by-1.0.0-rust.fune, or fetch it from a terminal with fune pull collections.sort-by@1.0.0:rust.
The whole function, every language, is one file too: collections.sort-by-1.0.0.fune, 19,865 bytes, sha256 64f59dd149f5eee9c1e84e5a62ff365767cda76a85825b539a3a053a40a080c6. 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.sort-by
after — your function gets the result and the arguments, and returns the final result.
// fune: after collections.sort-by
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.sort-by --steps.
// fune: step collections.sort-by 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 | |
|---|---|---|---|
| ascending by a string key | records ×3, n, asc | → | ×3 |
| descending by a string key | records ×3, n, desc | → | ×3 |
| ascending by a number key | records ×3, s, asc | → | ×3 |
| descending by a number key | records ×3, s, desc | → | ×3 |
| ties keep their input order ascending | records ×4, s, asc | → | ×4 |
| ties keep their input order descending too: the tied block is not flipped | records ×4, s, desc | → | ×4 |
| nulls and missing keys sort last ascending, keeping their own order | records ×4, s, asc | → | ×4 |
| nulls stay last descending: the rows we know nothing about never come first | records ×4, s, desc | → | ×4 |
| a list of nothing but absent values comes back in input order | records ×3, s, asc | → | ×3 |
| an empty list sorts to an empty list | , s, asc | → |
Show the other 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a single record is already sorted, in either direction | records ×1, s, desc | → | ×1 |
| mixed types have one total order ascending: booleans, then numbers, then strings | records ×6, v, asc | → | ×6 |
| mixed types reverse cleanly descending | records ×6, v, desc | → | ×6 |
| integers and fractions interleave by value, not by type | records ×4, v, asc | → | ×4 |
| strings compare by code point, so capitals sort before lowercase | records ×4, v, asc | → | ×4 |
| a prefix sorts before the longer string, and accented letters sort after ASCII | records ×4, v, asc | → | ×4 |
| records with different shapes sort on the one field they share | records ×3, n, asc | → | ×3 |
| an unknown direction is an error, not a silent ascending sort | records ×1, s, ascending | → | error: direction must be "asc" or "desc" |
| a list at the sort key has no defensible order | records ×2, s, asc | → | error: list or map |
| a map at the sort key has no defensible order | records ×2, s, asc | → | error: list or map |
| an empty key name is a caller bug | records ×1, , asc | → | error: non-empty key name |
More from the author
A missing key and a null value are the same thing and both sort last.
Strings compare by Unicode code point, spelled out rather than inherited: JavaScript's < compares UTF-16 code units and disagrees with Python and Rust above U+FFFF. Comparison is case-sensitive, so 'Zebra' sorts before 'apple'; lowercase the key first if you want a case-insensitive sort.
A list or a map at the sort key is an error, and it is detected in one pass before sorting starts. Discovering it mid-comparison would make the error depend on which comparisons that language's sort happened to perform.
Files
| Path | Bytes |
|---|---|
| README.md | 1,268 |
| impl/python.py | 3,315 |
| impl/rust.rs | 3,698 |
| impl/typescript.ts | 3,794 |
| vectors.json | 4,914 |