Functional Weave
Code in TypeScript

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 place
  • dedupeByKey(records ×3, id, last) → ×2 keep last: the latest record survives, at the latest record's position
  • dedupeByKey(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>>[]
recordsrecord[]open JSON-ish maps; shapes may differ between records
keystringthe field whose value identifies a record
keepDedupeKeepwhich of a set of duplicates survives
returnsrecord[]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";
impl/typescript.ts · 77 lines · open · raw
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
Download for TypeScript collections.dedupe-by-key-1.0.0-typescript.fune · 10,214 bytes sha256 c466be72c960fc0b8477dda843959f3d0628ec997d0f3a72df6902ae03139fc9

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,856
impl/python.py2,851
impl/rust.rs2,902
impl/typescript.ts2,951
vectors.json2,969