Functional Weave
Code in Python

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 places
  • format_decimal(2.5, 2, false, ) → 2.50 fixed places keep trailing zeros
  • format_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
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
trim_zerosbooldrop trailing zeros after the point, and the point itself if nothing is left
group_separatorstringput 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

from fune.text.format_decimal import format_decimal  # text.format-decimal@^1
impl/python.py · 29 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.

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 body

Install

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
Download for Python text.format-decimal-1.0.0-python.fune · 6,996 bytes sha256 17d8a797041548896d19b2f75e404ed3c3df799ccbda9d8ecc2d2b885969abdc

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.

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