Functional Weave
Code in TypeScript

stats.mean-median

Mean, median and mode of a list of integers, with the mean and median as exact fractions.

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

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

What it does

Mean, median and mode of a list of integers, in one pass over one sorted copy.

The mean and median are returned as exact fractions in lowest terms (`7/2`, not `3.5` and certainly not `3`, which is what integer division gives). Each also comes as a float, `meanValue` and `medianValue`, computed by one IEEE-754 division of the exact numerator by the exact denominator. That single division is correctly rounded in every language, so TypeScript, Python and Rust return the same double to the last bit; there is no accumulated float error to disagree about, because the sum is an exact integer.

For example

  • meanMedianMode(1, 2, 3, 4) → count 4, sum 10, mean …, mean value 2.5, median …, median value 2.5, modes 1, 2, 3, 4, mode frequency 1 an even count has a fractional mean and median
  • meanMedianMode(3, 4) → count 2, sum 7, mean …, mean value 3.5, median …, median value 3.5, modes 3, 4, mode frequency 1 the mean of 3 and 4 is 7/2, not the 3 integer division gives
  • meanMedianMode(2, 2, 3, 9, 1) → count 5, sum 17, mean …, mean value 3.4, median …, median value 2, modes 2, mode frequency 2 unsorted odd-length input takes the middle of the sorted values

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 meanMedianMode(values: readonly number[]): CentralTendency
valuesint[]whole numbers in any order, at least one; scale decimals first (pence, grams)
returnsCentralTendency

The types it declares, generated into your project

/** An exact rational number in lowest terms; the denominator is always positive. */
export interface Fraction {
  readonly numerator: number;
  readonly denominator: number;
}

/** The three averages of one list, exact where they can be. */
export interface CentralTendency {
  readonly count: number;
  readonly sum: number;
  readonly mean: Fraction;
  /** sum / count, the nearest binary64 to the exact mean */
  readonly meanValue: number;
  /** denominator 1 or 2 */
  readonly median: Fraction;
  readonly medianValue: number;
  /** every value with the highest frequency, ascending */
  readonly modes: readonly number[];
  /** 1 means no value repeats, so every value is a mode */
  readonly modeFrequency: number;
}

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

import { meanMedianMode } from "#fune/stats.mean-median@^1";
impl/typescript.ts · 94 lines · open · raw
import { type CentralTendency, type Fraction } from "./stats_mean_median_types.ts";

function gcd(a: number, b: number): number {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b !== 0) {
    const t = a % b;
    a = b;
    b = t;
  }
  return a;
}

function fraction(numerator: number, denominator: number): Fraction {
  const g = gcd(numerator, denominator) || 1;
  // `+ 0` turns -0 into 0 so a zero mean prints the same everywhere.
  return { numerator: numerator / g + 0, denominator: denominator / g };
}

/**
 * Mean, median and mode of a list of integers.
 *
 * Mean and median are exact fractions; their float forms come from a single
 * correctly rounded division, so every language returns the same double.
 */
export function meanMedianMode(values: readonly number[]): CentralTendency {
  if (!Array.isArray(values)) throw new TypeError("values must be a list of integers");
  if (values.length === 0) throw new RangeError("values must not be empty");
  for (const v of values) {
    if (typeof v !== "number" || !Number.isInteger(v)) {
      throw new TypeError(`values must be integers, received ${v}`);
    }
    if (!Number.isSafeInteger(v)) {
      throw new RangeError(`values must be safe integers (magnitude at most 9007199254740991), received ${v}`);
    }
  }

  // Sum in BigInt so an overflow is detected rather than silently rounded.
  let total = BigInt(0);
  for (const v of values) total += BigInt(v);
  if (total > BigInt(Number.MAX_SAFE_INTEGER) || total < -BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new RangeError("sum exceeds the safe integer range");
  }
  const sum = Number(total);
  const count = values.length;

  // Numeric sort on a copy: the default sort compares as strings (10 < 9).
  const sorted = [...values].sort((a, b) => a - b);
  let median: Fraction;
  if (count % 2 === 1) {
    median = { numerator: sorted[(count - 1) / 2] + 0, denominator: 1 };
  } else {
    const pair = BigInt(sorted[count / 2 - 1]) + BigInt(sorted[count / 2]);
    const two = BigInt(2);
    if (pair % two === BigInt(0)) {
      median = { numerator: Number(pair / two) + 0, denominator: 1 };
    } else {
      if (pair > BigInt(Number.MAX_SAFE_INTEGER) || pair < -BigInt(Number.MAX_SAFE_INTEGER)) {
        throw new RangeError("median exceeds the safe integer range");
      }
      median = { numerator: Number(pair), denominator: 2 };
    }
  }

  // Runs of equal values in the sorted copy give frequencies in ascending
  // value order, so the modes come out sorted without a second sort.
  let modes: number[] = [];
  let modeFrequency = 0;
  let i = 0;
  while (i < count) {
    let j = i;
    while (j < count && sorted[j] === sorted[i]) j++;
    const run = j - i;
    if (run > modeFrequency) {
      modeFrequency = run;
      modes = [sorted[i] + 0];
    } else if (run === modeFrequency) {
      modes.push(sorted[i] + 0);
    }
    i = j;
  }

  const mean = fraction(sum, count);
  return {
    count,
    sum: sum + 0,
    mean,
    meanValue: sum / count + 0,
    median,
    medianValue: median.numerator / median.denominator + 0,
    modes,
    modeFrequency,
  };
}

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 stats.mean-median
Download for TypeScript stats.mean-median-1.0.0-typescript.fune · 11,963 bytes sha256 94700e4284f7ff11d9285fee90af0f0183933c967d34c9963ca7a157e0f489c0

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

The whole function, every language, is one file too: stats.mean-median-1.0.0.fune, 19,364 bytes, sha256 f90bdb4452a1f9b675c30352a8731b29cf937dc78ea902e7b0d2a54d83d9bdf5. 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 stats.mean-median

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

// fune: after stats.mean-median

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 stats.mean-median --steps.

// fune: step stats.mean-median 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
an even count has a fractional mean and median 1, 2, 3, 4 → count 4, sum 10, mean …, mean value 2.5, median …, median value 2.5, modes 1, 2, 3, 4, mode frequency 1
the mean of 3 and 4 is 7/2, not the 3 integer division gives 3, 4 → count 2, sum 7, mean …, mean value 3.5, median …, median value 3.5, modes 3, 4, mode frequency 1
unsorted odd-length input takes the middle of the sorted values 2, 2, 3, 9, 1 → count 5, sum 17, mean …, mean value 3.4, median …, median value 2, modes 2, mode frequency 2
the median sorts numerically, not as strings (10 is not before 9) 10, 9, 1 → count 3, sum 20, mean …, mean value 6.667, median …, median value 9, modes 1, 9, 10, mode frequency 1
tied modes are all returned, ascending 5, 3, 5, 3, 1 → count 5, sum 17, mean …, mean value 3.4, median …, median value 3, modes 3, 5, mode frequency 2
negative values keep the sign on the numerator -3, -2 → count 2, sum -5, mean …, mean value -2.5, median …, median value -2.5, modes -3, -2, mode frequency 1
a single value is its own mean, median and mode 7 → count 1, sum 7, mean …, mean value 7, median …, median value 7, modes 7, mode frequency 1
a zero mean is 0/1 in lowest terms -4, 4 → count 2, sum 0, mean …, mean value 0, median …, median value 0, modes -4, 4, mode frequency 1
fractions are reduced to lowest terms 2, 4, 6, 8 → count 4, sum 20, mean …, mean value 5, median …, median value 5, modes 2, 4, 6, 8, mode frequency 1
a repeating mean is exact as a fraction 1, 1, 2 → count 3, sum 4, mean …, mean value 1.333, median …, median value 1, modes 1, mode frequency 2
Show the other 6 tests
CaseArgumentsExpected
values at the edge of the safe range 9,007,199,254,740,991, -9,007,199,254,740,991 → count 2, sum 0, mean …, mean value 0, median …, median value 0, modes -9,007,199,254,740,991, 9,007,199,254,740,991, mode frequency 1
an empty list is an error → error: values must not be empty
a fractional value is an error 1, 2.5 → error: values must be integers
a value past 2^53 - 1 is an error 9,007,199,254,740,992 → error: values must be safe integers
a sum past the safe range is an error, not a rounded answer 9,007,199,254,740,991, 1 → error: sum exceeds the safe integer range
an odd median pair past the safe range is an error -9,007,199,254,740,991, 4,503,599,627,370,496, 4,503,599,627,370,497, 4,503,599,627,370,497 → error: median exceeds the safe integer range

More from the author

Inputs are integers on purpose. Averages of money, weights or counts should be taken in their smallest unit (pence, grams), where they are exact; divide at the edge. Every value must be a safe integer (magnitude at most 2^53 - 1, the range all three languages share), and so must the sum. A list whose sum leaves that range is an error, not a silently rounded answer. The same goes for the median of an even-length list when the two middle values add up past it and do not halve evenly.

The median sorts numerically. A naive JavaScript `values.sort()` sorts as strings and puts 10 before 9; the vectors pin that down. The input list is never mutated.

`modes` lists every value that shares the highest frequency, ascending. When no value repeats, `modeFrequency` is 1 and every value is listed: whether that counts as "no mode" is the caller's call, and `modeFrequency` makes it a one-line test.

An empty list is an error: there is no honest mean of nothing.

Files

PathBytes
README.md1,577
impl/python.py2,829
impl/rust.rs4,284
impl/typescript.ts3,117
vectors.json4,002