Functional Weave
Code in TypeScript

math.basis-points

Convert a rate between percent, basis points and a plain ratio exactly, as decimal text or integer basis points.

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

Pinned by 45 tests, run in TypeScript, Python and Rust.convertRate 19 · toBasisPoints 13 · fromBasisPoints 13

What it does

100 basis points is 1 percent is a ratio of 0.01. The three units differ only by powers of ten, so converting between them is moving a decimal point, and `convertRate` does exactly that on the text of the number. `"0.07"` as a ratio is `"7"` percent, where `0.07 * 100` in floating point is `7.000000000000001`.

Values are decimal strings in and out, never floats, so any rate a person can write down converts without loss and without a size limit. Output is the shortest form: no leading zeros, no trailing fractional zeros, no trailing point, and zero is `"0"` (never `"-0"`).

The functions

A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. convertRate (value: string, fromUnit: RateUnit, toUnit: RateUnit) -> string
  2. toBasisPoints (value: string, unit: RateUnit) -> int
  3. fromBasisPoints (basisPoints: int, unit: RateUnit) -> string

The type it declares, generated into your project

export type RateUnit = "percent" | "basis-points" | "ratio";

Once installed, your code imports each one from the group's module.

convertRate throws on bad input 19 tests

export function convertRate(value: string, fromUnit: RateUnit, toUnit: RateUnit): string
valuestringa plain decimal: digits, an optional leading "-", an optional "." with digits after it
fromUnitRateUnitthe unit value is in
toUnitRateUnitthe unit wanted
returnsstringthe same rate in the new unit, as the shortest exact decimal

For example

  • convertRate(12.5, percent, basis-points) → 1250 12.5 percent is 1250 basis points
  • convertRate(1250, basis-points, percent) → 12.5 1250 basis points is 12.5 percent
  • convertRate(0.125, ratio, percent) → 12.5 a ratio of 0.125 is 12.5 percent
import { convertRate } from "#fune/math.basis-points@^2";
impl/typescript/convert_rate.ts · 47 lines · open · raw
import { type RateUnit } from "./math_basis_points_types.ts";

const DECIMAL = /^-?[0-9]+(\.[0-9]+)?$/;

/** Powers of ten between each unit and basis points. */
function exponent(unit: RateUnit): number {
  switch (unit) {
    case "basis-points":
      return 0;
    case "percent":
      return 2;
    case "ratio":
      return 4;
    default:
      throw new RangeError(`unknown rate unit "${unit}"`);
  }
}

/**
 * Convert a rate between percent, basis points and a ratio.
 *
 * The units differ by powers of ten, so this moves the decimal point in the
 * text itself: exact for any input, with no float anywhere.
 */
export function convertRate(value: string, fromUnit: RateUnit, toUnit: RateUnit): string {
  if (typeof value !== "string" || !DECIMAL.test(value)) {
    throw new RangeError(`"${value}" is not a decimal number`);
  }
  const shift = exponent(fromUnit) - exponent(toUnit);

  const negative = value.startsWith("-");
  const body = negative ? value.slice(1) : value;
  const point = body.indexOf(".");
  let digits = point < 0 ? body : body.slice(0, point) + body.slice(point + 1);
  let position = (point < 0 ? body.length : point) + shift;

  if (position > digits.length) digits = digits + "0".repeat(position - digits.length);
  if (position < 0) {
    digits = "0".repeat(-position) + digits;
    position = 0;
  }

  const whole = digits.slice(0, position).replace(/^0+/, "") || "0";
  const fraction = digits.slice(position).replace(/0+$/, "");
  const text = fraction ? `${whole}.${fraction}` : whole;
  return negative && text !== "0" ? "-" + text : text;
}

toBasisPoints throws on bad input 13 tests

export function toBasisPoints(value: string, unit: RateUnit): number
valuestringa plain decimal, as convertRate takes it
unitRateUnitthe unit value is in
returnsintwhole basis points; an error if the rate is not a whole number of them or is beyond ±(2^53 - 1)

For example

  • toBasisPoints(20, percent) → 2,000 20 percent is 2000 basis points
  • toBasisPoints(12.5, percent) → 1,250 12.5 percent is 1250 basis points
  • toBasisPoints(0.0525, ratio) → 525 a ratio of 0.0525 is 525 basis points
import { toBasisPoints } from "#fune/math.basis-points@^2";
impl/typescript/to_basis_points.ts · 21 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 { convertRate } from "./math_basis_points_convert_rate.ts";  ← convertRate, another function of this group · built into the same file, even by a slim install
import { type RateUnit } from "./math_basis_points_types.ts";

/**
 * A rate as whole basis points, the unit the registry keeps rates in.
 * Refuses a rate that is not a whole number of basis points (12.345% is
 * 1234.5) rather than round it: which way to round a rate is the caller's
 * policy, not a conversion's.
 */
export function toBasisPoints(value: string, unit: RateUnit): number {
  const text = convertRate(value, unit, "basis-points");
  if (text.includes(".")) {
    throw new RangeError(`${value} ${unit} is not a whole number of basis points`);
  }
  const bp = Number(text);
  // Past 2^53 - 1, Number() has already rounded, so it is refused, not returned.
  if (!Number.isSafeInteger(bp)) {
    throw new RangeError(`${value} ${unit} is too large for integer basis points`);
  }
  return bp;
}

fromBasisPoints throws on bad input 13 tests

export function fromBasisPoints(basisPoints: number, unit: RateUnit): string
basisPointsintan integer within ±(2^53 - 1)
unitRateUnitthe unit wanted
returnsstringthe rate as the shortest exact decimal

For example

  • fromBasisPoints(20%, percent) → 20 2000 basis points is 20 percent
  • fromBasisPoints(12.5%, percent) → 12.5 1250 basis points is 12.5 percent
  • fromBasisPoints(12.5%, ratio) → 0.125 1250 basis points is a ratio of 0.125
import { fromBasisPoints } from "#fune/math.basis-points@^2";
impl/typescript/from_basis_points.ts · 13 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 { convertRate } from "./math_basis_points_convert_rate.ts";  ← convertRate, another function of this group · built into the same file, even by a slim install
import { type RateUnit } from "./math_basis_points_types.ts";

/** Integer basis points as the shortest exact decimal in another unit. */
export function fromBasisPoints(basisPoints: number, unit: RateUnit): string {
  if (!Number.isInteger(basisPoints)) {
    throw new TypeError(`basisPoints must be an integer, received ${basisPoints}`);
  }
  if (!Number.isSafeInteger(basisPoints)) {
    throw new RangeError("basisPoints is outside the safe integer range (±9007199254740991)");
  }
  return convertRate(String(basisPoints), "basis-points", unit);
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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 math.basis-points

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add math.basis-points --only convertRate
Download for TypeScript math.basis-points-2.0.0-typescript.fune · 16,994 bytes sha256 0ad56667c891e83bb9acab8f62641822ec5f1a4ff7d9875cea61f4a302929e36

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

The whole function, every language, is one file too: math.basis-points-2.0.0.fune, 25,143 bytes, sha256 8f577c1f8da63586073a8df9632faa5df78e65aec48b6eace456101dc5c12935. 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 math.basis-points.convertRate
// fune: before math.basis-points.toBasisPoints
// fune: before math.basis-points.fromBasisPoints

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

// fune: after math.basis-points.convertRate
// fune: after math.basis-points.toBasisPoints
// fune: after math.basis-points.fromBasisPoints

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show math.basis-points --steps.

// fune: step math.basis-points.<fn> 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.

convertRate 19 tests

CaseArgumentsExpected
12.5 percent is 1250 basis points 12.5, percent, basis-points → 1250
1250 basis points is 12.5 percent 1250, basis-points, percent → 12.5
a ratio of 0.125 is 12.5 percent 0.125, ratio, percent → 12.5
20 percent is a ratio of 0.2 20, percent, ratio → 0.2
one basis point is a ratio of 0.0001 1, basis-points, ratio → 0.0001
half a basis point as a ratio needs leading zeros 0.5, basis-points, ratio → 0.00005
0.07 as a ratio is exactly 7 percent, not 7.000000000000001 0.07, ratio, percent → 7
a negative rate keeps its sign -2.75, percent, basis-points → -275
same unit gives the canonical form 007.500, percent, percent → 7.5
zero with trailing zeros is plain zero 0.00, ratio, basis-points → 0
Show the other 9 tests
CaseArgumentsExpected
negative zero is zero -0.0, percent, ratio → 0
a ratio of one is 10000 basis points 1, ratio, basis-points → 10000
numbers beyond any float or integer convert exactly 123456789012345678901.23, percent, basis-points → 12345678901234567890123
a percent sign is not part of the number 12.5%, percent, basis-points → error: is not a decimal number
exponent notation is refused 1e3, basis-points, percent → error: is not a decimal number
a bare leading point is refused .5, percent, ratio → error: is not a decimal number
a decimal comma is refused 1,5, percent, ratio → error: is not a decimal number
an empty string is refused , percent, ratio → error: is not a decimal number
an unknown unit is an error 5, permille, percent → error: unknown rate unit

toBasisPoints 13 tests

CaseArgumentsExpected
20 percent is 2000 basis points 20, percent → 2,000
12.5 percent is 1250 basis points 12.5, percent → 1,250
a ratio of 0.0525 is 525 basis points 0.0525, ratio → 525
basis points given as basis points, trailing zeros allowed 75.000, basis-points → 75
a negative rate keeps its sign -0.75, percent → -75
zero percent is zero 0.00, percent → 0
negative zero is zero -0, ratio → 0
the largest safe number of basis points 9007199254740991, basis-points → 9,007,199,254,740,991
12.345 percent is 1234.5 basis points, which no integer holds 12.345, percent → error: is not a whole number of basis points
a ratio finer than a basis point is refused, not rounded 0.00001, ratio → error: is not a whole number of basis points
Show the other 3 tests
CaseArgumentsExpected
one past 2^53 - 1 basis points is too large 9007199254740992, basis-points → error: too large for integer basis points
a percent sign is not part of the number 20%, percent → error: is not a decimal number
an unknown unit is an error 5, permille → error: unknown rate unit

fromBasisPoints 13 tests

CaseArgumentsExpected
2000 basis points is 20 percent 20%, percent → 20
1250 basis points is 12.5 percent 12.5%, percent → 12.5
1250 basis points is a ratio of 0.125 12.5%, ratio → 0.125
one basis point is 0.01 percent 0.01%, percent → 0.01
one basis point is a ratio of 0.0001 0.01%, ratio → 0.0001
basis points to basis points is the same number -0.4%, basis-points → -40
a negative rate keeps its sign -2.75%, percent → -2.75
zero is plain zero 0%, ratio → 0
10000 basis points is a ratio of exactly one 100%, ratio → 1
the largest safe number of basis points as a ratio 90071992547409.9%, ratio → 900719925474.0991
Show the other 3 tests
CaseArgumentsExpected
2^53 basis points is outside the safe range 90071992547409.92%, percent → error: outside the safe integer range
a fractional number of basis points is refused 0.125%, percent → error: must be an integer
an unknown unit is an error 1%, permille → error: unknown rate unit

More from the author

Input is strict: an optional leading `-`, digits, and optionally `.` followed by digits. `"12.5%"`, `"+5"`, `".5"`, `"5."`, `"1e3"`, `"1,5"` and spaces are all errors. Strip a `%` sign before calling; this is not a parser for free-form text.

The registry keeps rates as integer basis points, so two functions cover the common edges. `toBasisPoints(value, unit)` returns an integer and refuses a rate that is not a whole number of basis points (12.345% is 1234.5 basis points, which no integer holds; rounding it is the caller's policy) or is beyond ±(2^53 - 1). `fromBasisPoints(bp, unit)` renders an integer basis-point rate as the shortest decimal string in another unit.

This is a group of three functions, each in its own file. `toBasisPoints` and `fromBasisPoints` are built on `convertRate`, so installing either with `only=` brings `convertRate` too.

## What changed from 1.0.0

1.0.0 was one function, `convertRate`, with `toBasisPoints` and `fromBasisPoints` exported beside it but unpinned: no signature in the manifest and no vectors. 2.0.0 is a group in which both are published functions with their own signatures and vectors. `convertRate` is unchanged and keeps every 1.0.0 vector.

`fromBasisPoints` changed behaviour, because the three languages did not agree on it in 1.0.0: TypeScript refused a value beyond ±(2^53 - 1) and a fraction ("basisPoints must be a safe integer"), Python refused only a non-integer (with a different message) and converted any size, and Rust converted any `i64`. In 2.0.0 all three refuse a fraction with "basisPoints must be an integer" and a value beyond ±(2^53 - 1) with "basisPoints is outside the safe integer range". `toBasisPoints` gives the same answers and errors as before.

That behaviour change, and the move to one module per function (the group module `math_basis_points` still re-exports all three; the Python module no longer exports its `MAX_SAFE` constant), make this a major version. Nothing in the registry depended on 1.0.0.

Files

PathBytes
README.md2,600
impl/python/convert_rate.py1,558
impl/python/from_basis_points.py794
impl/python/to_basis_points.py781
impl/rust/convert_rate.rs2,361
impl/rust/from_basis_points.rs1,119
impl/rust/to_basis_points.rs996
impl/typescript/convert_rate.ts1,590
impl/typescript/from_basis_points.ts623
impl/typescript/to_basis_points.ts879
vectors.json6,119