Functional Weave
Code in TypeScript

todo.sort

Sort todos by manual order, due date, priority, creation time or title, stably, with sensible tie-breakers.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

What it does

Sorts todos for a list view by one key, with fixed tie-breakers so the order never jumps around between renders. Built from stable collections.sort-by passes, from the least to the most significant key, then mapped back to the original todos.

| key | primary | ties, always ascending | | --- | --- | --- | | `manual` | `order` | creation time, then input order | | `due` | due date | priority high to none, then `order` | | `priority` | none 0, low 1, medium 2, high 3 (`desc` is high first) | due date, then `order` | | `created` | `createdAt` | `order` | | `title` | title with A-Z folded to a-z, then code-point order | `order` |

For example

  • sortTodos(todos ×4, manual, asc) → ×4 manual ascending follows order
  • sortTodos(todos ×4, manual, desc) → ×4 manual descending reverses order
  • sortTodos(todos ×4, due, asc) → ×4 due ascending: earliest first, same day high priority first, no date last

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 sortTodos(todos: readonly Todo[], by: TodoSortKey, direction: SortDirection): readonly Todo[]
todosTodo[]
byTodoSortKeythe primary key; each has fixed ascending tie-breakers
directionSortDirectionapplies to the primary key only; todos with no due date stay last either way
returnsTodo[]a new list; the input is not reordered

The type it declares, generated into your project

export type TodoSortKey = "manual" | "due" | "priority" | "created" | "title";

Your code names it in one line, in the file that uses it

import { sortTodos } from "#fune/todo.sort@^1";
impl/typescript.ts · 67 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { foldAscii } from "./collections_search_text.ts";  ← from collections.search-text ^1.0.0 · built alongside by fune
import { sortBy } from "./collections_sort_by.ts";  ← from collections.sort-by ^1.0.0 · built alongside by fune
import { type SortDirection } from "./collections_sort_by_types.ts";
import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { isUtcTimestamp } from "./todo_item_validate_todo.ts";
import { type TodoSortKey } from "./todo_sort_types.ts";

const RANKS: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3 };

/** Sort passes, least significant first; the last pass is the primary key and takes the direction. */
const PASSES: Record<string, [string, "asc" | "desc" | "dir"][]> = {
  manual: [["created", "asc"], ["order", "dir"]],
  due: [["order", "asc"], ["rank", "desc"], ["due", "dir"]],
  priority: [["order", "asc"], ["due", "asc"], ["rank", "dir"]],
  created: [["order", "asc"], ["created", "dir"]],
  title: [["order", "asc"], ["title", "dir"]],
};

function rank(priority: string): number {
  const r = RANKS[priority];
  if (r === undefined || !Object.prototype.hasOwnProperty.call(RANKS, priority)) {
    throw new RangeError(`unknown priority "${priority}": expected none, low, medium or high`);
  }
  return r;
}

/**
 * A creation time that sorts as text: the fraction padded to nine digits, so
 * "...:00Z" (no fraction) comes before "...:00.5Z" as it should, where the raw
 * strings would put "." before "Z".
 */
function createdKey(createdAt: string): string {
  if (!isUtcTimestamp(createdAt)) {
    throw new RangeError(`createdAt "${createdAt}" is not a UTC timestamp, e.g. 2026-09-28T09:30:00Z`);
  }
  const fraction = createdAt.length > 20 ? createdAt.slice(20, createdAt.length - 1) : "";
  return createdAt.slice(0, 19) + "." + fraction.padEnd(9, "0");
}

/**
 * Todos in the order a list shows them for the chosen key. Stable, built from
 * collections.sort-by passes from the least to the most significant key. The
 * direction flips only the primary key; tie-breakers always run ascending, and
 * todos with no due date stay last in both directions.
 */
export function sortTodos(todos: readonly Todo[], by: TodoSortKey, direction: SortDirection): readonly Todo[] {
  const passes = Object.prototype.hasOwnProperty.call(PASSES, by) ? PASSES[by] : undefined;
  if (passes === undefined) {
    throw new RangeError(`unknown sort key "${by}": expected manual, due, priority, created or title`);
  }
  if (direction !== "asc" && direction !== "desc") {
    throw new RangeError(`direction must be "asc" or "desc", received "${direction}"`);
  }
  const used = new Set(passes.map(([field]) => field));
  let records: readonly Readonly<Record<string, unknown>>[] = todos.map((todo, i) => {
    const r: Record<string, unknown> = { i, order: todo.order };
    if (used.has("created")) r.created = createdKey(todo.createdAt);
    if (used.has("rank")) r.rank = rank(todo.priority);
    if (used.has("due")) r.due = todo.due ?? null;
    if (used.has("title")) r.title = foldAscii(todo.title);
    return r;
  });
  for (const [field, dir] of passes) {
    records = sortBy(records, field, dir === "dir" ? direction : dir);
  }
  return records.map((r) => todos[r.i as number]);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, 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 todo.sort
Download for TypeScript todo.sort-1.0.0-typescript.fune · 37,293 bytes sha256 a003727a16536b18ff4124e8bc39018db5450d43bb1f8b6e76be4b8d0d83648e

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.sort-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.sort@1.0.0:typescript.

The whole function, every language, is one file too: todo.sort-1.0.0.fune, 43,900 bytes, sha256 c1d30450c183f2c1d6ceb267684929429e8bf879c49ad01d2e3803d07240012e. 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 todo.sort

after — your function gets the result and the arguments, and returns the final result.

// fune: after todo.sort

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace collections.search-text in todo.sort
// fune: replace collections.sort-by in todo.sort
// fune: replace todo.item in todo.sort

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 todo.sort --steps.

// fune: step todo.sort 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
manual ascending follows order todos ×4, manual, asc → ×4
manual descending reverses order todos ×4, manual, desc → ×4
due ascending: earliest first, same day high priority first, no date last todos ×4, due, asc → ×4
due descending: latest first, but no date still last; ties still high priority first todos ×4, due, desc → ×4
priority ascending: none, low, then the two highs by due date, no date last todos ×4, priority, asc → ×4
priority descending: high first, ties by due date ascending with no date last todos ×4, priority, desc → ×4
created ascending: 09:00:00Z before 09:00:00.5Z (plain text order gets this wrong) todos ×4, created, asc → ×4
created descending todos ×4, created, desc → ×4
title ascending ignores case: apple, apple pie, Banana, cherry todos ×4, title, asc → ×4
title descending ignores case todos ×4, title, desc → ×4
Show the other 10 tests
CaseArgumentsExpected
titles equal but for case tie on order todos ×3, title, asc → ×3
tie-breakers stay ascending when the title sort is descending todos ×3, title, desc → ×3
manual ties go by creation time, then input order todos ×3, manual, asc → ×3
manual ties stay oldest first when descending todos ×3, manual, desc → ×3
no due date is last descending too, and those keep order todos ×3, due, desc → ×3
an empty list , due, asc →
an unknown sort key is an error todos ×4, alphabetical, asc → error: unknown sort key "alphabetical": expected manual, due, priority, created or title
an unknown direction is an error todos ×4, title, up → error: direction must be "asc" or "desc", received "up"
sorting by creation time needs real timestamps todos ×1, created, asc → error: createdAt "yesterday" is not a UTC timestamp
sorting by priority needs a known priority todos ×1, priority, asc → error: unknown priority "urgent": expected none, low, medium or high

More from the author

- `direction` flips the primary key only. Todos with no due date are last in both directions, in the `due` sort and as a tie-breaker, because a descending due list should start with the latest real date, not the undated. - Anything still tied keeps its input order. - Creation times are compared as instants, not raw text: `…09:00:00Z` sorts before `…09:00:00.5Z`, which plain string comparison gets backwards (`.` comes before `Z`). The `manual` and `created` sorts therefore need every `createdAt` to be a UTC timestamp and fail loudly otherwise; the `due` and `priority` sorts need a known priority. The other sorts do not look at those fields. - Title case folding is ASCII only (collections.search-text's `foldAscii`), so "apple" and "Banana" sort as a person expects, identically in every language; accented letters sort after z by code point. - An unknown key or direction is an error.

Files

PathBytes
README.md1,563
impl/python.py2,568
impl/rust.rs3,614
impl/typescript.ts3,116
vectors.json26,544