Functional Weave
Code in TypeScript

money.parse

Parse "£1,234.50" or "1.234,50 €" into integer minor units, strictly: no guessing, no rounding.

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

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

What it does

Turns text such as `"£1,234.50"`, `"1.234,50 €"`, `"CHF 1'234.50"` or `"1 234,56 kr"` into a `Money` in integer minor units, and refuses anything it would have to guess at.

**The caller says the currency and the number style.** `"1.234"` is one thousand two hundred and thirty-four in Germany and one point two three four in the UK, and no amount of cleverness can tell which from the text alone. So:

For example

  • parseMoney(£1,234.50, GBP, comma-dot) → £1,234.50 pounds with a thousands comma
  • parseMoney(1.234,50 €, EUR, dot-comma) → €1,234.50 euros written the German way, symbol after
  • parseMoney(€1,234.50, EUR, comma-dot) → €1,234.50 euros written the Irish way

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 parseMoney(text: string, currency: string, style: NumberStyle): Money
textstringwhat the person typed or the document says, e.g. "£1,234.50"
currencystringthe currency the amount must be in; a symbol in the text has to agree
styleNumberStylewhich characters group thousands and mark the decimal
returnsMoney

The type it declares, generated into your project

export type NumberStyle = "comma-dot" | "dot-comma" | "space-comma" | "apostrophe-dot";

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

import { parseMoney } from "#fune/money.parse@^1";
impl/typescript.ts · 115 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 { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { currencyDigits } from "./money_currency_digits.ts";  ← from money.currency-digits ^1.0.0 · built alongside by fune
import { CURRENCY_DIGITS } from "./money_currency_digits_data.ts";  ← money.currency-digits’s rule data (^1.0.0) · built alongside by fune
import { CURRENCY_SYMBOLS } from "./money_parse_data.ts";  ← this capability’s own data, compiled from data/currency-symbols.json into the same file by fune build
import { type NumberStyle } from "./money_parse_types.ts";

const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
// Space, no-break space, narrow no-break space: what real documents use.
const SPACES = [" ", " ", " "];

function styleOf(style: NumberStyle): { groups: string[]; decimal: string } {
  switch (style) {
    case "comma-dot":
      return { groups: [","], decimal: "." };
    case "dot-comma":
      return { groups: ["."], decimal: "," };
    case "space-comma":
      return { groups: SPACES, decimal: "," };
    case "apostrophe-dot":
      return { groups: ["'", "’"], decimal: "." };
    default:
      throw new RangeError(`unknown number style "${style}"`);
  }
}

function isDigits(s: string): boolean {
  return /^[0-9]+$/.test(s);
}

/** The longest known symbol or ISO code that `matches`, or null. */
function longestSymbol(matches: (symbol: string) => boolean): string | null {
  let best: string | null = null;
  const candidates = [...CURRENCY_SYMBOLS.map((r) => r.symbol), ...CURRENCY_DIGITS.map((r) => r.code)];
  for (const symbol of candidates) {
    if (matches(symbol) && (best === null || symbol.length > best.length)) best = symbol;
  }
  return best;
}

/** Whole digits and fraction digits, or null when the text is not a number in this style. */
function splitNumber(s: string, groups: string[], decimal: string): [string, string] | null {
  const parts = s.split(decimal);
  if (parts.length > 2) return null;
  const fraction = parts.length === 2 ? parts[1] : "";
  if (parts.length === 2 && !isDigits(fraction)) return null;

  const chunks: string[] = [""];
  for (const ch of parts[0]) {
    if (groups.includes(ch)) chunks.push("");
    else chunks[chunks.length - 1] += ch;
  }
  if (!chunks.every(isDigits)) return null;
  if (chunks.length > 1) {
    // Real thousands grouping only: 1-3 digits without a leading zero, then threes.
    const [first, ...rest] = chunks;
    if (first.length > 3 || first.startsWith("0")) return null;
    if (!rest.every((c) => c.length === 3)) return null;
  }
  return [chunks.join(""), fraction];
}

/**
 * Parse an amount written as text into integer minor units.
 *
 * Strict by design: the caller names the currency and the number style, and
 * anything ambiguous (too many decimals, odd grouping, a symbol for another
 * currency) is an error rather than a guess.
 */
export function parseMoney(text: string, currency: string, style: NumberStyle): Money {
  const { groups, decimal } = styleOf(style);
  const digits = currencyDigits(currency);
  const invalid = () => new RangeError(`"${text}" is not a valid amount`);

  let s = text.replace(/^ +| +$/g, "");
  let negative = false;
  if (s.startsWith("-")) {
    negative = true;
    s = s.slice(1);
  }

  let symbol = longestSymbol((k) => s.startsWith(k));
  if (symbol !== null) {
    s = s.slice(symbol.length);
    if (SPACES.includes(s.charAt(0))) s = s.slice(1);
    if (!negative && s.startsWith("-")) {
      negative = true;
      s = s.slice(1);
    }
  } else {
    symbol = longestSymbol((k) => s.endsWith(k));
    if (symbol !== null) {
      s = s.slice(0, s.length - symbol.length);
      if (SPACES.includes(s.charAt(s.length - 1))) s = s.slice(0, -1);
    }
  }

  if (symbol !== null && symbol !== currency && !CURRENCY_SYMBOLS.some((r) => r.code === currency && r.symbol === symbol)) {
    throw new RangeError(`currency symbol "${symbol}" does not match ${currency}`);
  }

  const number = splitNumber(s, groups, decimal);
  if (number === null) throw invalid();
  const [whole, fraction] = number;
  if (fraction.length > digits) {
    throw new RangeError(`too many decimal places for ${currency} (at most ${digits})`);
  }

  const all = (whole + fraction.padEnd(digits, "0")).replace(/^0+/, "");
  const minor = BigInt(all === "" ? "0" : all);
  if (minor > MAX_SAFE) {
    throw new RangeError(`amount is too large: "${text}" exceeds 2^53 - 1 minor units`);
  }
  const value = Number(minor);
  return money(negative && value !== 0 ? -value : value, currency);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 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 money.parse
Download for TypeScript money.parse-1.0.0-typescript.fune · 19,061 bytes sha256 e05b9194a110bc81462ff182c13503d7ea05196451bd113b1361c4ace980291e

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

The whole function, every language, is one file too: money.parse-1.0.0.fune, 28,750 bytes, sha256 ecbdeba4a39292c61457306e88c137e00908b4f639810f6736ead0376b3c3162. 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 money.parse

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

// fune: after money.parse

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 money.amount in money.parse
// fune: replace money.currency-digits in money.parse

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 money.parse --steps.

// fune: step money.parse 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
pounds with a thousands comma £1,234.50, GBP, comma-dot → £1,234.50
euros written the German way, symbol after 1.234,50 €, EUR, dot-comma → €1,234.50
euros written the Irish way €1,234.50, EUR, comma-dot → €1,234.50
millions in the German style 1.234.567,89 €, EUR, dot-comma → €1,234,567.89
no grouping and one decimal digit 1234.5, GBP, comma-dot → £1,234.50
whole pounds £5, GBP, comma-dot → £5.00
pence only £0.05, GBP, comma-dot → £0.05
minus before the symbol -£12.34, GBP, comma-dot → -£12.34
minus after the symbol £-12.34, GBP, comma-dot → -£12.34
negative zero is zero -0.00, GBP, comma-dot → £0.00
Show the other 27 tests
CaseArgumentsExpected
yen have no decimal places ¥1,500, JPY, comma-dot → ¥1,500
dinar have three decimal places KD 1.500, KWD, comma-dot → 1.500 KWD
Swiss apostrophe grouping with the ISO code CHF 1'234'567.89, CHF, apostrophe-dot → 1,234,567.89 CHF
Swedish space grouping, trailing symbol 1 234,56 kr, SEK, space-comma → 1,234.56 SEK
no-break spaces, as word processors write them 1 234,56 kr, SEK, space-comma → 1,234.56 SEK
French narrow no-break space grouping 1 234,56 €, EUR, space-comma → €1,234.56
an ISO code after the number 10.00 GBP, GBP, comma-dot → £10.00
a dollar sign is fine once the caller says Canadian dollars $5.00, CAD, comma-dot → 5.00 CAD
US$ is recognised as one symbol US$9.99, USD, comma-dot → $9.99
surrounding spaces are ignored £5.00 , GBP, comma-dot → £5.00
one space between symbol and number £ 5.00, GBP, comma-dot → £5.00
the largest amount that is still exact 90071992547409.91, GBP, comma-dot → £90,071,992,547,409.91
three decimals for pounds is refused, not rounded £1,234.567, GBP, comma-dot → error: too many decimal places
1.500 yen is not fifteen hundred yen in comma-dot style 1.500, JPY, comma-dot → error: too many decimal places
a German thousand read in the UK style is refused, not read as 1.23 £1.234, GBP, comma-dot → error: too many decimal places
lakh grouping is not thousands grouping £1,23,456.00, GBP, comma-dot → error: is not a valid amount
the other style's separators are refused 1,234.50, EUR, dot-comma → error: is not a valid amount
a symbol for another currency is refused €5.00, GBP, comma-dot → error: does not match GBP
R$ is Brazilian real, not rand R$10,00, ZAR, space-comma → error: does not match ZAR
letters are refused 12abc, GBP, comma-dot → error: is not a valid amount
a symbol alone is refused £, GBP, comma-dot → error: is not a valid amount
an empty string is refused , GBP, comma-dot → error: is not a valid amount
a trailing decimal point is refused £5., GBP, comma-dot → error: is not a valid amount
accounting brackets are refused (£5.00), GBP, comma-dot → error: is not a valid amount
beyond 2^53 - 1 minor units is refused 90071992547409.92, GBP, comma-dot → error: amount is too large
an unknown currency is an error 5.00, XYZ, comma-dot → error: no ISO 4217 minor unit
an unknown style is an error 5.00, GBP, indian → error: unknown number style

More from the author

| style | groups thousands with | decimal mark | |------------------|---------------------------------------|--------------| | `comma-dot` | `,` | `.` | | `dot-comma` | `.` | `,` | | `space-comma` | space, no-break space or narrow no-break space | `,` | | `apostrophe-dot` | `'` or `’` | `.` |

Grouping is optional, but when it is used it must be real thousands grouping: one to three digits, then groups of exactly three (`1,234,567`). `1,23,456` (Indian lakh grouping) and `12,34` are refused rather than read as something.

**The decimal places come from ISO 4217** via `money.currency-digits`. More decimals than the currency has is an error, not a rounding: `"£1.234"` in the `comma-dot` style is refused, because either it is a typo or it is a German thousand, and neither should become £1.23. Fewer are fine: `"£5"` and `"£5.5"` are 500 and 550 pence. Yen take no decimal part at all.

**A symbol must agree with the currency.** One currency symbol or ISO code may appear, before the number or after it, with at most one space (ordinary, no-break or narrow no-break) between. It must be the currency's own ISO code or one of its symbols in `data/currency-symbols.json`; `"€5"` parsed as GBP is an error. `"$"` is accepted for every dollar and peso in the table because the caller has already said which one it is; `"US$"` is only accepted for USD.

**Signs.** A leading `-`, before or after a leading symbol (`-£5`, `£-5`). Accounting brackets, trailing minus signs and `+` are refused.

**Whitespace.** Ordinary spaces around the whole text are ignored; nothing else is trimmed.

The result must fit in ±(2^53 - 1) minor units. Anything else - letters, two decimal marks, a group separator from another style, an empty string - is `"… is not a valid amount"`.

The symbol table is a convenience for recognising common symbols, not a standard; codes are always accepted. The decimal places are ISO 4217 List One (see `money.currency-digits` for the source).

Files

PathBytes
README.md2,562
data/currency-symbols.json1,486
impl/python.py4,094
impl/rust.rs5,175
impl/typescript.ts4,273
vectors.json6,929