Functional Weave
Code in TypeScript

text.format-decimal

Format a float as plain decimal text with fixed or trimmed places and optional grouping, identically in every language.

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

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

What it does

Turns a float into decimal text: `formatDecimal(1234567.891, 2, false, ",")` is `"1,234,567.89"`. It exists because the three languages print floats differently: `String(1e-7)` is `"1e-7"`, Python's `str(1e-7)` is `"1e-07"`, Rust prints `0.0000001`, and `2.0` is `"2"`, `"2.0"` and `"2"`. Anything that puts numbers into text that must match across languages (SVG path data, axis labels, CSV) should go through this.

The value is rounded once, half away from zero, by `math.round-float` (so `2.675` to 2 places is `2.67`, because that is the double actually stored, and `0.125` is `0.13`). The rounded value becomes a whole count of 10^-decimals units, and the text is built from that integer's digits. There is never an exponent, and never `-0`: a negative number that rounds to zero prints as `0`, or `0.00` with fixed places.

For example

  • formatDecimal(3.142, 2, false, ) → 3.14 two fixed places
  • formatDecimal(2.5, 2, false, ) → 2.50 fixed places keep trailing zeros
  • formatDecimal(2.5, 2, true, ) → 2.5 trimmed places drop trailing zeros

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 formatDecimal(value: number, decimals: number, trimZeros: boolean, groupSeparator: string): string
valuefloatany finite number whose magnitude times 10^decimals is below 10^15
decimalsint0 to 12 places, rounded half away from zero by math.round-float
trimZerosbooldrop trailing zeros after the point, and the point itself if nothing is left
groupSeparatorstringput between groups of three digits in the whole part, e.g. "," or ""
returnsstring

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

import { formatDecimal } from "#fune/text.format-decimal@^1";
impl/typescript.ts · 38 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 { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune

const POW10 = [1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12];

/**
 * A float as decimal text, never in exponent form and never "-0".
 *
 * String(x), str(x) and Rust's Display disagree about floats: 1e21, 1e-7 and
 * 2.0 print differently in each. So the number is rounded once, turned into a
 * whole count of 10^-decimals units, and the digits of that integer are laid
 * out by hand.
 */
export function formatDecimal(value: number, decimals: number, trimZeros: boolean, groupSeparator: string): string {
  const rounded = roundFloat(value, decimals);
  const scaled = Math.abs(rounded) * POW10[decimals];
  if (scaled >= 1e15) {
    throw new RangeError(`value is too large to format at ${decimals} decimal places, received ${value}`);
  }
  // rounded is within half an ulp of a whole number of units; below 10^15 the
  // product cannot drift as far as the next one, so rounding recovers it.
  const units = roundFloat(scaled, 0);
  let digits = String(units);
  while (digits.length < decimals + 1) digits = "0" + digits;
  const whole = digits.slice(0, digits.length - decimals);
  let fraction = digits.slice(digits.length - decimals);
  if (trimZeros) {
    let end = fraction.length;
    while (end > 0 && fraction[end - 1] === "0") end--;
    fraction = fraction.slice(0, end);
  }
  let grouped = "";
  for (let i = 0; i < whole.length; i++) {
    if (i > 0 && (whole.length - i) % 3 === 0) grouped += groupSeparator;
    grouped += whole[i];
  }
  const body = fraction.length > 0 ? grouped + "." + fraction : grouped;
  return units > 0 && rounded < 0 ? "-" + body : body;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, 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 text.format-decimal
Download for TypeScript text.format-decimal-1.0.0-typescript.fune · 7,397 bytes sha256 d8779fb1c8064b0728c928dfeda4e7ced7158ee6cee3950205ae559707455018

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

The whole function, every language, is one file too: text.format-decimal-1.0.0.fune, 10,670 bytes, sha256 dfb1ce562594c30b832d373293ea7f9748721400553b6297338c3bfdf3e1962b. 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 text.format-decimal

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

// fune: after text.format-decimal

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 math.round-float in text.format-decimal

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 text.format-decimal --steps.

// fune: step text.format-decimal 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
two fixed places 3.142, 2, false, → 3.14
fixed places keep trailing zeros 2.5, 2, false, → 2.50
trimmed places drop trailing zeros 2.5, 2, true, → 2.5
a whole number trims to no point at all 7, 2, true, → 7
zero places 1,234.5, 0, false, → 1235
thousands grouped with a comma 1,234,567.891, 2, false, , → 1,234,567.89
grouping with a thin space, only in the whole part 12,345.679, 4, false, → 12 345.6789
exactly three digits get no separator 999, 0, false, , → 999
a negative number -1,234.5, 1, false, , → -1,234.5
a negative that rounds to zero prints 0, not -0 -0.004, 2, false, → 0.00
Show the other 9 tests
CaseArgumentsExpected
a small number keeps its leading zeros, never 1e-7 0, 7, false, → 0.0000001
a large number is never 1e+21 style 123,456,789,012, 0, false, → 123456789012
2.675 is stored below the tie, so 2.67 (as toFixed) 2.675, 2, false, → 2.67
0.125 is a true tie and goes away from zero 0.125, 2, false, → 0.13
binary noise disappears 0.3, 12, true, → 0.3
fractional zero trims to 0 0, 3, true, , → 0
rounding carries into a new group 999,999.996, 2, false, , → 1,000,000.00
too many digits for an exact count is an error 1,000,000,000,000,000, 0, false, → error: value is too large to format at 0 decimal places
more than 12 places is an error 1, 13, false, → error: decimals must be a whole number from 0 to 12

More from the author

`trimZeros` drops trailing zeros after the point, then the point itself if nothing is left: `2.50` becomes `2.5`, `7.00` becomes `7`. `groupSeparator` goes between groups of three digits of the whole part only (`","`, `"."`, `" "`, or `""` for none). The decimal point is always `.` and the minus sign the ASCII hyphen; for currency amounts use `money.format`, which knows each currency's digits and symbols.

The count of units must stay below 10^15, where a double still counts in whole numbers exactly; a larger value (say 1e15 with no decimals, or 1000 at 12 places) is an error rather than a string with invented digits.

Files

PathBytes
README.md1,480
impl/python.py1,273
impl/rust.rs1,869
impl/typescript.ts1,663
vectors.json2,084