Functional Weave
Code in TypeScript

money.convert

Convert an amount to another currency at a supplied exact rate, with an explicit rounding mode.

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

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

What it does

Converts a `Money` into another currency at a rate the caller supplies. It does not look rates up: which rate applies (the day's ECB reference rate, the rate on the invoice date, a contract rate) is the caller's decision, and this takes it as an argument, so the same inputs always give the same answer.

**The rate is an exact fraction**, a `math.rational` `Rational`, in major units, the way rates are quoted: GBP to EUR at 1.1734 is `{ base: "GBP", quote: "EUR", rate: { numerator: 11734, denominator: 10000 } }`. A decimal quote becomes a fraction by moving the point (`math.basis-points` style), and the inverse rate is the same fraction upside down, exactly, so converting back uses precisely the reciprocal rather than 1/1.1734 cut off at some number of places. A fraction was chosen over "rate in micro-units" because micro-units cannot hold a reciprocal and cap every rate at six decimal places.

For example

  • convertMoney(£100.00, base GBP, quote EUR, rate …, half-up) → €117.34 100.00 GBP at 1.1734 is 117.34 EUR
  • convertMoney(£12.34, base GBP, quote EUR, rate …, half-up) → €14.48 12.34 GBP at 1.1734 is 14.479756 EUR, rounded half-up
  • convertMoney(£12.34, base GBP, quote EUR, rate …, down) → €14.47 the same rounded down

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 convertMoney(amount: Money, rate: ExchangeRate, mode: RoundingMode): Money
amountMoneymust be in the rate's base currency
rateExchangeRatethe rate to apply; the caller chooses it for the right date
modeRoundingModehow to round to the quote currency's minor unit
returnsMoney

The type it declares, generated into your project

/** A quoted rate: one unit of base buys rate units of quote, both in major units. */
export interface ExchangeRate {
  /** ISO 4217 code converted from */
  readonly base: string;
  /** ISO 4217 code converted to */
  readonly quote: string;
  /** exact, e.g. 1.1734 is {numerator 11734, denominator 10000} */
  readonly rate: Rational;
}

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

import { convertMoney } from "#fune/money.convert@^1";
impl/typescript.ts · 30 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 { multiplyRational, rational, rationalToInteger } from "./math_rational.ts";  ← from math.rational ^1.0.0 · built alongside by fune
import { type RoundingMode } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
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 { type ExchangeRate } from "./money_convert_types.ts";

/**
 * Convert `amount` into the rate's quote currency.
 *
 * Exact up to one final rounding into the quote currency's minor unit; the
 * rate is a fraction in major units and each currency's decimal places are
 * looked up rather than assumed to be two.
 */
export function convertMoney(amount: Money, rate: ExchangeRate, mode: RoundingMode): Money {
  if (amount.currency !== rate.base) {
    throw new RangeError(`exchange rate converts ${rate.base} to ${rate.quote}, not ${amount.currency}`);
  }
  const r = rational(rate.rate.numerator, rate.rate.denominator);
  if (r.numerator <= 0) {
    throw new RangeError("exchange rate must be positive");
  }
  const fromDigits = currencyDigits(rate.base);
  const toDigits = currencyDigits(rate.quote);

  // minor units of quote per minor unit of base: rate x 10^toDigits / 10^fromDigits
  const scale = rational(10 ** toDigits, 10 ** fromDigits);
  const perMinor = multiplyRational(r, scale);
  const exact = multiplyRational(rational(amount.minor, 1), perMinor);
  return money(rationalToInteger(exact, mode), rate.quote);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 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.convert
Download for TypeScript money.convert-1.0.0-typescript.fune · 14,008 bytes sha256 e81622bd1d50d46c6b0b952b9223a94484165a9c462c20a3385af203d28095cf

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

The whole function, every language, is one file too: money.convert-1.0.0.fune, 17,457 bytes, sha256 5f212485febe18b862f284258bf82a2443c493df5d26c3d20f0303bc6fdf5691. 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.convert

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

// fune: after money.convert

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.rational in money.convert
// fune: replace math.round-div in money.convert
// fune: replace money.amount in money.convert
// fune: replace money.currency-digits in money.convert

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.convert --steps.

// fune: step money.convert 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
100.00 GBP at 1.1734 is 117.34 EUR £100.00, base GBP, quote EUR, rate …, half-up → €117.34
12.34 GBP at 1.1734 is 14.479756 EUR, rounded half-up £12.34, base GBP, quote EUR, rate …, half-up → €14.48
the same rounded down £12.34, base GBP, quote EUR, rate …, down → €14.47
a refund converts symmetrically -£12.34, base GBP, quote EUR, rate …, half-up → -€14.48
pounds to yen: 10.00 at 190.12 is 1,901 yen, not 190,120 £10.00, base GBP, quote JPY, rate …, half-up → ¥1,901
yen back to pounds at the exact reciprocal ¥1,901, base JPY, quote GBP, rate …, half-up → £10.00
pounds to Kuwaiti dinar, three decimal places £100.00, base GBP, quote KWD, rate …, half-up → 38.120 KWD
1.005 exactly rounds up where a float gives 100.49999999999999 €1.00, base EUR, quote USD, rate …, half-up → $1.01
half a cent rounds up under half-up £0.05, base GBP, quote EUR, rate …, half-up → €0.03
half a cent rounds to even under half-even £0.05, base GBP, quote EUR, rate …, half-even → €0.02
Show the other 9 tests
CaseArgumentsExpected
an unreduced rate is read as its value £100.00, base GBP, quote EUR, rate …, half-up → €117.34
zero converts to zero £0.00, base GBP, quote EUR, rate …, half-up → €0.00
a same-currency rate of one is the identity £43.21, base GBP, quote GBP, rate …, half-up → £43.21
a GBP to EUR rate on a USD amount is an error $10.00, base GBP, quote EUR, rate …, half-up → error: exchange rate converts GBP to EUR, not USD
a zero rate is an error £10.00, base GBP, quote EUR, rate …, half-up → error: exchange rate must be positive
a negative rate is an error £10.00, base GBP, quote EUR, rate …, half-up → error: exchange rate must be positive
a zero denominator is an error £10.00, base GBP, quote EUR, rate …, half-up → error: denominator must not be zero
gold has no minor unit to convert into £10.00, base GBP, quote XAU, rate …, half-up → error: no ISO 4217 minor unit
a product too large to hold exactly is refused £90,071,992,547,409.91, base GBP, quote EUR, rate …, half-up → error: rational overflow

More from the author

**Minor units are handled for you.** The rate is in major units, so the amount is scaled by each currency's ISO 4217 decimal places (from `money.currency-digits`): 10.00 GBP at 190.12 is 1,901 JPY, not 190,120.

**One rounding**, at the end, to the quote currency's minor unit, with the `math.round-div` mode you pass. Everything before it is exact. 1.00 EUR at 1.005 is exactly 1.005 USD; `half-up` gives 1.01, where a float computes 100 × 1.005 as 100.49999999999999 and rounds it to 1.00.

**The rate carries its currencies.** Applying a GBP-to-EUR rate to a USD amount is an error, not a conversion. To go the other way, swap `base` and `quote` and turn the fraction over; this never inverts a rate on its own.

Edges: the rate must be positive; zero converts to zero; negative amounts (refunds) convert symmetrically under `half-up` and `half-even`. Intermediate values follow `math.rational`'s limits (numerator and denominator within 2^53 - 1 after reducing); an amount and rate so large that the exact product does not fit is refused with "rational overflow" rather than rounded.

Files

PathBytes
README.md2,012
impl/python.py1,303
impl/rust.rs2,026
impl/typescript.ts1,398
vectors.json7,186