collections.group-by-key
Group records by the value at one key, preserving input order inside each group.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Within a group, records keep the order they arrived in. That is the property callers actually depend on - grouping a sorted list must not unsort it - and it is guaranteed in all three languages.
A missing key and a null value are the same thing: both group under the empty string "". A JSON document that omits a field and one that sets it to null mean the same thing to every reader, and dropping those records would silently lose data from a total. The cost is that a record whose value is genuinely "" lands in the same group; in practice an empty string is a missing value, and if the distinction matters, normalise before calling.
For example
group_by_key(records ×3, team)→ core ×2, ops ×1 records fall into buckets named by the value at the keygroup_by_key(records ×5, team)→ a ×3, b ×2 grouping a sorted list does not unsort it: input order survives inside each groupgroup_by_key(records ×1, team)→ core ×1 one record is one group of one
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 group_by_key(records: &[Value], key: &str) -> Vec<(String, Vec<Value>)>
| records | record[] | open JSON-ish maps; shapes may differ between records |
| key | string | the field whose value names the group |
| returns | map<record[]> | map from group name to the records in that group, in input order |
Your code names it in one line, in the file that uses it
fune!(collections.group-by-key@^1); // then call group_by_key(…)
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
/// Largest integer JavaScript can hold exactly; beyond it the three languages disagree.
const SAFE_INTEGER: i64 = 9007199254740991;
/// The name of the group a value belongs to.
///
/// Each language has its own default string conversion and they disagree
/// (Python prints True where JavaScript prints true, and 1.0 where JavaScript
/// prints 1), so the rendering is spelled out here instead of inherited.
///
/// # Panics
/// Panics on a float, a list or a map, or an integer outside the safe range.
fn group_name_of(value: &Value, key: &str) -> String {
match value {
// Absent and null are the same thing: `Value::get` returns Null for a
// missing field, and a document that nulls a field means the same.
Value::Null => String::new(),
Value::Str(s) => s.clone(),
Value::Bool(b) => (if *b { "true" } else { "false" }).to_string(),
Value::Int(i) => {
if i.abs() > SAFE_INTEGER {
panic!("cannot group by the out-of-range number {} at \"{}\"", i, key);
}
i.to_string()
}
Value::Float(f) => panic!("cannot group by the fractional number {} at \"{}\"", f, key),
_ => panic!("cannot group by the list or map at \"{}\"", key),
}
}
/// Group `records` by the value at `key`.
///
/// Records keep their input order inside each group, so grouping a sorted list
/// never unsorts it - the property callers actually depend on.
///
/// 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. The result
/// is the groups in first-appearance order, as `map<T>` is in Rust.
pub fn group_by_key(records: &[Value], key: &str) -> Vec<(String, Vec<Value>)> {
let mut groups: Vec<(String, Vec<Value>)> = Vec::new();
for record in records {
let name = group_name_of(record.get(key), key);
// Linear scan rather than a hash map: it keeps first-appearance order
// without a second pass, and group counts are small in practice.
match groups.iter_mut().find(|(existing, _)| *existing == name) {
Some((_, bucket)) => bucket.push(record.clone()),
None => groups.push((name, vec![record.clone()])),
}
}
groups
}
/// How many records fall in each group, without carrying the records themselves.
pub fn count_by_key(records: &[Value], key: &str) -> Vec<(String, i64)> {
group_by_key(records, key)
.into_iter()
.map(|(name, bucket)| (name, bucket.len() as i64))
.collect()
}
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 args[1].as_str().is_empty() {
panic!("groupByKey needs a non-empty key name");
}
Value::Obj(
group_by_key(args[0].as_arr(), args[1].as_str())
.into_iter()
.map(|(name, bucket)| (name, Value::Arr(bucket)))
.collect(),
)
}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.group-by-key
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./collections.group-by-key-1.0.0-rust.fune, or fetch it from a terminal with fune pull collections.group-by-key@1.0.0:rust.
The whole function, every language, is one file too: collections.group-by-key-1.0.0.fune, 16,841 bytes, sha256 640bae4c083fa730ec305773e5380386cb168bcaf3f4e6d84a52df863327dc20. 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.group-by-key
after — your function gets the result and the arguments, and returns the final result.
// fune: after collections.group-by-key
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.group-by-key --steps.
// fune: step collections.group-by-key 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 | |
|---|---|---|---|
| records fall into buckets named by the value at the key | records ×3, team | → | core ×2, ops ×1 |
| grouping a sorted list does not unsort it: input order survives inside each group | records ×5, team | → | a ×3, b ×2 |
| one record is one group of one | records ×1, team | → | core ×1 |
| no records is no groups, not a group of nothing | , team | → | |
| every distinct value is its own group | records ×3, team | → | a ×1, b ×1, c ×1 |
| one shared value is one group holding everything | records ×3, team | → | a ×3 |
| a missing key groups under the empty string rather than dropping the record | records ×2, team | → | a ×1, ×1 |
| a null value and a missing key land in the same group, because they mean the same | records ×3, team | → | ×2, a ×1 |
| integers name their group by their decimal digits | records ×3, owner | → | 17 ×2, 42 ×1 |
| a negative integer keeps its sign in the group name | records ×3, delta | → | 0 ×1, -3 ×2 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| booleans render as true and false in every language, not True and False | records ×3, active | → | true ×2, false ×1 |
| records with different shapes group on the one field they share | records ×3, kind | → | invoice ×2, credit ×1 |
| the empty string is an ordinary group name when it is the real value | records ×2, team | → | ×1, a ×1 |
| a fractional number cannot name a group: the three languages print it differently | records ×1, rate | → | error: fractional number |
| a list cannot name a group | records ×1, tags | → | error: list or map |
| a map cannot name a group | records ×1, meta | → | error: list or map |
| an integer too large to survive a JavaScript round trip is rejected | records ×1, id | → | error: out-of-range number |
| an empty key name is a caller bug | records ×1, | → | error: non-empty key name |
More from the author
Group names are derived by an explicit rendering, not by each language's default string conversion, because those disagree: a string is itself, an integer is its decimal digits, true and false are "true" and "false".
A float, a list or a map at the key is an error. A float has no decimal rendering the three languages agree on (Python prints 1.0, JavaScript prints 1), and there is no defensible name for a group identified by a list. Integers outside the +/-2^53 safe range are rejected for the same reason: JavaScript would render them in exponential notation or lose digits.
Key order in the returned map is first-appearance order everywhere except JavaScript, which reorders integer-like object keys ahead of the rest. Do not rely on the order of the groups; sort the keys if you need one. The order WITHIN each group is guaranteed.
Files
| Path | Bytes |
|---|---|
| README.md | 1,506 |
| impl/python.py | 2,433 |
| impl/rust.rs | 3,109 |
| impl/typescript.ts | 2,769 |
| vectors.json | 4,532 |