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
format_decimal(3.142, 2, false, )→ 3.14 two fixed placesformat_decimal(2.5, 2, false, )→ 2.50 fixed places keep trailing zerosformat_decimal(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.
def format_decimal(value: float, decimals: int, trim_zeros: bool, group_separator: str) -> str
| value | float | any finite number whose magnitude times 10^decimals is below 10^15 |
| decimals | int | 0 to 12 places, rounded half away from zero by math.round-float |
| trim_zeros | bool | drop trailing zeros after the point, and the point itself if nothing is left |
| group_separator | string | put between groups of three digits in the whole part, e.g. "," or "" |
| returns | string |
Your code names it in one line, in the file that uses it
from fune.text.format_decimal import format_decimal # text.format-decimal@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .math_round_float import round_float ← from math.round-float ^1.0.0 · built alongside by fune
POW10 = [1.0, 10.0, 100.0, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12]
def format_decimal(value: float, decimals: int, trim_zeros: bool, group_separator: str) -> str:
"""A float as decimal text, never in exponent form and never "-0".
str(), String() and Rust's Display disagree about floats (1e-07, 1e-7,
0.0000001), 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.
"""
rounded = round_float(value, decimals)
scaled = abs(rounded) * POW10[decimals]
if scaled >= 1e15:
raise ValueError("value is too large to format at %d decimal places, received %r" % (decimals, value))
units = int(round_float(scaled, 0))
digits = str(units).rjust(decimals + 1, "0")
whole = digits[: len(digits) - decimals]
fraction = digits[len(digits) - decimals:]
if trim_zeros:
fraction = fraction.rstrip("0")
grouped = ""
for i, ch in enumerate(whole):
if i > 0 and (len(whole) - i) % 3 == 0:
grouped += group_separator
grouped += ch
body = grouped + "." + fraction if fraction else grouped
return "-" + body if units > 0 and rounded < 0 else bodyInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python 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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./text.format-decimal-1.0.0-python.fune, or fetch it from a terminal with fune pull text.format-decimal@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,480 |
| impl/python.py | 1,273 |
| impl/rust.rs | 1,869 |
| impl/typescript.ts | 1,663 |
| vectors.json | 2,084 |