collections.dedupe-by-key
Remove records that share a key value, keeping the first or the last of each, in input order.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 14 tests, run in TypeScript, Python and Rust.
What it does
Removes records that share the value at `key`, keeping either the first or the last record of each set of duplicates. `first` is "the original wins" (an import that must not overwrite); `last` is "the latest wins" (a change feed where later rows supersede earlier ones).
WHERE SURVIVORS SIT: every survivor keeps its own position relative to the other survivors. With `keep = last`, the surviving record sits where the last occurrence was, not where the first one was:
For example
dedupeByKey(records ×3, id, first)→ ×2 keep first: the earliest record of each key survives, in its own placededupeByKey(records ×3, id, last)→ ×2 keep last: the latest record survives, at the latest record's positiondedupeByKey(records ×5, id, last)→ ×3 three copies, keep last, interleaved with others
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 dedupeByKey(records: readonly Readonly<Record<string, unknown>>[], key: string, keep: DedupeKeep): readonly Readonly<Record<string, unknown>>[]
| records | record[] | open JSON-ish maps; shapes may differ between records |
| key | string | the field whose value identifies a record |
| keep | DedupeKeep | which of a set of duplicates survives |
| returns | record[] | a new list; each survivor stays at its own position in the input |
The type it declares, generated into your project
export type DedupeKeep = "first" | "last";
Your code names it in one line, in the file that uses it
import { dedupeByKey } from "#fune/collections.dedupe-by-key@^1";
import { type DedupeKeep } from "./collections_dedupe_by_key_types.ts";
/** An open record, the manifest's `record`: a JSON-ish map whose shape is not known ahead of time. */
type DedupeRecord = Readonly<Record<string, unknown>>;
/** Largest integer JavaScript can hold exactly; beyond it the three languages disagree. */
const SAFE_INTEGER = 9007199254740991;
/**
* The identity of a record, or null when it has none.
*
* Rendered exactly as collections.group-by-key renders a group name, so the
* two capabilities agree on what "the same key" means. Each language's
* default string conversion differs (Python prints True, JavaScript true), so
* the rendering is spelled out rather than inherited.
*/
function keyOf(value: unknown, key: string): string | null {
// A record with no key is not a duplicate of anything: two records that
// both lack an id are two unknowns, not one thing seen twice.
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isInteger(value)) {
throw new TypeError(`cannot dedupe by the fractional number ${value} at "${key}"`);
}
if (Math.abs(value) > SAFE_INTEGER) {
throw new RangeError(`cannot dedupe by the out-of-range number ${value} at "${key}"`);
}
return String(value);
}
throw new TypeError(`cannot dedupe by the list or map at "${key}"`);
}
/**
* Remove records that share the value at `key`, keeping the first or the
* last of each set.
*
* Survivors keep their own positions: with "last", the survivor sits where
* the last occurrence was, which is what "latest wins" means in a change feed.
*/
export function dedupeByKey(
records: readonly DedupeRecord[],
key: string,
keep: DedupeKeep,
): readonly DedupeRecord[] {
if (!Array.isArray(records)) {
throw new TypeError("dedupeByKey needs a list of records");
}
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("dedupeByKey needs a non-empty key name");
}
if (keep !== "first" && keep !== "last") {
throw new RangeError(`keep must be "first" or "last", received "${keep}"`);
}
// Every key is rendered up front, so a bad value raises whichever mode runs.
const keys = records.map((record) =>
keyOf(record === null || record === undefined ? undefined : record[key], key),
);
const seen = new Set<string>();
const survives: boolean[] = new Array(records.length).fill(false);
// Walking backwards for "last" makes the last occurrence the first one seen.
const order = records.map((_, i) => i);
if (keep === "last") order.reverse();
for (const i of order) {
const k = keys[i];
if (k === null) {
survives[i] = true;
} else if (!seen.has(k)) {
seen.add(k);
survives[i] = true;
}
}
return records.filter((_, i) => survives[i]);
}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.dedupe-by-key
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./collections.dedupe-by-key-1.0.0-typescript.fune, or fetch it from a terminal with fune pull collections.dedupe-by-key@1.0.0:typescript.
The whole function, every language, is one file too: collections.dedupe-by-key-1.0.0.fune, 16,235 bytes, sha256 05480a9dc85245dd71b28201102ce3496e166eff75901d8ba8c05fb5ae139f26. 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.dedupe-by-key
after — your function gets the result and the arguments, and returns the final result.
// fune: after collections.dedupe-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.dedupe-by-key --steps.
// fune: step collections.dedupe-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 | |
|---|---|---|---|
| keep first: the earliest record of each key survives, in its own place | records ×3, id, first | → | ×2 |
| keep last: the latest record survives, at the latest record's position | records ×3, id, last | → | ×2 |
| three copies, keep last, interleaved with others | records ×5, id, last | → | ×3 |
| three copies, keep first | records ×5, id, first | → | ×3 |
| no duplicates leaves the list as it was | records ×3, id, first | → | ×3 |
| an empty list stays empty | , id, last | → | |
| records without the key are always kept, never collapsed into one | records ×5, id, first | → | ×4 |
| the number 1 and the string "1" are the same key, as in group-by-key | records ×2, id, last | → | ×1 |
| case matters: "A" and "a" are different keys | records ×2, id, first | → | ×2 |
| booleans are keys too | records ×3, flag, last | → | ×2 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an unknown keep mode is an error | records ×1, id, middle | → | error: keep must be "first" or "last" |
| an empty key name is an error | records ×1, , first | → | error: needs a non-empty key name |
| a fractional number at the key is an error, not a guess | records ×1, id, first | → | error: cannot dedupe by the fractional number |
| a list at the key is an error | records ×1, id, first | → | error: cannot dedupe by the list or map |
More from the author
[a#1, b#1, a#2] keep first -> [a#1, b#1] [a#1, b#1, a#2] keep last -> [b#1, a#2]
That is what a change feed means by "latest wins": the survivor is the latest row, in the latest row's place. A caller who wants the latest values in the original slot can dedupe with `last` and re-sort.
WHAT COUNTS AS THE SAME KEY: values are compared by the same rendering `collections.group-by-key` uses, so the two agree on what a key is. Strings are themselves, whole numbers are their decimal digits, booleans are `true` and `false`. That means the number `1` and the string `"1"` are the same key, as they are in a CSV, a query string and every JSON API that is loose about types. A fractional number, a list or a map at the key is an error, because there is no rendering of them all three languages agree on; so is a whole number beyond 2^53, which JavaScript cannot hold exactly.
MISSING KEYS ARE NEVER DUPLICATES: a record whose key is absent or null is always kept. Two records that both lack an id are two things we know nothing about, not one thing seen twice, and collapsing them would silently lose data. (This is where dedupe deliberately differs from group-by-key, which puts them in one "" group.)
Errors: a non-list, an empty key name, a `keep` other than `first` or `last`, and an ungroupable value at the key. The input is never modified.
Files
| Path | Bytes |
|---|---|
| README.md | 1,856 |
| impl/python.py | 2,851 |
| impl/rust.rs | 2,902 |
| impl/typescript.ts | 2,951 |
| vectors.json | 2,969 |