npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@arki/math

v0.0.10

Published

Zero-dependency math toolkit — exact Decimal and Rational arithmetic on bigint, explicit rounding modes, float helpers that round what you typed, descriptive statistics, Intl-backed locale formatting and parsing, seeded random, and a fluent number wrapper

Readme

@arki/math

Zero-dependency math toolkit for TypeScript. Exact where it must be, fast where it can be, and honest about which is which. Runs unchanged in Node 22+, Bun, Deno, browsers, and React Native (Hermes).

Installation

npm install @arki/math
# or
bun add @arki/math
# or
deno add npm:@arki/math

Two layers, by design

Exact types for money, measurements, calculators — anything where 0.1 + 0.2 !== 0.3 is a bug:

import { decimal } from '@arki/math/decimal';
import { ratio } from '@arki/math/rational';

decimal('0.1').plus('0.2').toString(); // '0.3'
decimal('19.99').times(3).toString(); // '59.97'  (scale is kept: 1.10 × 3 = 3.30)
decimal(1).dividedBy(8).toString(); // '0.125'  (exact, so no rounding needed)
decimal(1).dividedBy(3); // throws RoundingNecessaryError — say how to round:
decimal(1).dividedBy(3, { scale: 4, rounding: 'halfEven' }).toString(); // '0.3333'

ratio(1, 3).plus(ratio(1, 6)).toString(); // '1/2'
ratio('0.(3)').eq(ratio(1, 3)); // true  — repeating decimals parse
ratio('3.14159265').approximate(1000).toString(); // '355/113'

Plain-number helpers for UI, geometry, charts — fast float math with the sharp edges filed off:

import { clamp, inRange, lerp, round, roundTo } from '@arki/math/number';

round(1.005, 2); // 1.01  — rounds the number you typed, not its binary approximation
round(2.5, 0, 'halfEven'); // 2
roundTo(127.3, 5); // 125
clamp(105, 0, 100); // 100
lerp(0, 10, 0.5); // 5
inRange(5, 0, 5); // false — default bounds are [min, max)

Nothing in this package returns NaN or null to signal failure. Every deliberate failure is a typed error under MathError; wrong runtime types throw TypeError.

Modules

Everything is a subpath import, tree-shakeable by concern. The root @arki/math re-exports it all except linear-algebra, whose short names (plus, times, norm, inverse) would clash at the root.

| Import | What it gives you | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @arki/math/decimal | decimal(), Decimal — exact decimals with retained scale | | @arki/math/rational | ratio(), Rational — exact fractions on bigint | | @arki/math/number | clamp, lerp, inverseLerp, remap, inRange, round, roundTo, roundToSignificant, significantDigits, approxEqual, range, linspace | | @arki/math/integer | gcd, lcm, divmod, factorial, combinations, permutations, isPrime, primeFactors, divisors, modPow, modInverse, fibonacci, toBase, fromBase | | @arki/math/stats | sum, mean, median, mode, percentile, variance, stddev, minMax, correlation, zScore, confidenceInterval, sampleSize, linear/exponential/logarithmic/power/polynomial regression; mean…stddev exact on Decimal[] | | @arki/math/units | convert, convertExact, formatUnit — 22 categories, exact ratios | | @arki/math/finance | payment, amortizationSchedule, futureValue, netPresentValue, internalRateOfReturn, … — on Decimal; finance/float has the same verbs on number | | @arki/math/distributions | normal, studentT, noncentralT, chiSquare, fisherF, exponential, uniform, binomial, poisson, erf, erfInv, gamma, gammaLn, beta, … | | @arki/math/geometry | solveTriangle(s), circle, circleSector, ellipse, regularPolygon, solids, distance, polygonArea, pythagoreanTriples | | @arki/math/linear-algebra | solve, inverse, determinant, LU, pivoted QR, Cholesky, SVD, leastSquares, pseudoInverse, conditionNumber, rank, eigenvalues and eigenvectors — on plain arrays (subpath only) | | @arki/math/complex | complex(), Complex — arithmetic, polar form, exp, ln, sqrt, pow, nthRoots, sin…atanh | | @arki/math/polynomial | evaluatePolynomial, polynomialDerivative, quadraticRoots, cubicRoots, polynomialRoots | | @arki/math/decimal-math | sqrt, cbrt, nthRoot, exp, ln, log10, log2, log, pow, PI, E — correctly rounded, on Decimal | | @arki/math/words | spellNumber, spellOrdinal, formatOrdinal; locales en, enGB built in, words/ro, /de, /fr, /es, /it opt-in | | @arki/math/format | formatNumber, formatCurrency, formatPercent, formatCompact, formatFileSize, createFormatter | | @arki/math/parse | parseNumber, parseDecimal — locale-aware, the parser Intl never shipped | | @arki/math/random | createRandom(seed), randomInt, shuffle, sample, pick, … | | @arki/math/fluent | num() — immutable fluent wrapper for plain numbers | | @arki/math/rounding | RoundingMode, ROUNDING_MODES, isRoundingMode | | @arki/math/roots | findRoot (Brent), bisect, newton, findRoots — with an error estimate | | @arki/math/integrate | integrate — adaptive Gauss–Kronrod and tanh-sinh, infinite intervals | | @arki/math/differentiate | derivative (Ridders), limit (Richardson) — numerical estimates with an error estimate | | @arki/math/errors | MathError and its subclasses |

Deliberately not here: expression parsing and symbolic algebra (they are languages with their own security surface; they are planned as sibling packages built on this one), sparse matrices, and arbitrary-precision trigonometry. geometry, linear-algebra, complex, polynomial, roots, integrate and differentiate work on plain number (float64), like the rest of the numeric layer.

Rounding

One vocabulary everywhere — Decimal, Rational, the float helpers, and formatting all take the same RoundingMode:

| Mode | Meaning | 2.5 → | −2.5 → | | ------------- | ------------------------------------------------- | ----- | ------ | | up | away from zero | 3 | −3 | | down | toward zero | 2 | −2 | | ceiling | toward +∞ | 3 | −2 | | floor | toward −∞ | 2 | −3 | | halfUp | nearest, ties away from zero (school rounding) | 3 | −3 | | halfDown | nearest, ties toward zero | 2 | −2 | | halfEven | nearest, ties to even (banker's rounding) | 2 | −2 | | unnecessary | throw RoundingNecessaryError if digits are lost | — | — |

Defaults follow intent: display-shaped calls (round, roundTo, Decimal.round, toFixed, formatting) default to halfUp, which is what people expect to see. Precision-shaped calls (dividedBy, toScale, toDecimal) default to unnecessary, so precision is never lost without you asking.

Note that JavaScript's own Math.round(-2.5) is -2 (ties toward +∞) — none of the modes above, and one of the reasons this package exists.

Decimal

coefficient × 10^-scale, immutable: methods never modify their operands and fields are readonly. Instances are not frozen (freezing every result costs 5–7× in hot loops, and no peer library does it); the shared constants are. Inputs: string (strict ASCII decimal syntax, exponent allowed), number (read through its shortest decimal spelling, so decimal(0.1) is exactly 1/10; non-finite and unsafe integers throw), bigint, or another Decimal.

const d = decimal('1.10');
d.scale; // 2
d.plus('0.005').toString(); // '1.105'
d.minus(1).toString(); // '0.10'
d.times('3').toString(); // '3.30'
d.mod(0.25).toString(); // '0.10'
d.pow(2).toString(); // '1.2100'
d.pow(-1); // throws RoundingNecessaryError (1 / 1.1 does not terminate)

d.toScale(3).toString(); // '1.100'   adding digits is always allowed
decimal('1.15').toScale(1); // throws — would drop a non-zero digit
d.toScale(1, 'halfUp').toString(); // '1.1'
d.round(1).toString(); // '1.1'      round() = toScale() with halfUp default
d.toFixed(0); // '1'
d.trim().toString(); // '1.1'
d.roundTo('0.25').toString(); // '1.00'   nearest multiple of a step
formatNumber(d, { locale: 'de-DE' }); // '1,1'   display goes through @arki/math/format

d.eq('1.1'); // true — equality is numeric, scale does not matter
d.compareTo(2); // -1
d.lt(2) && d.gte(1); // true
d.isInteger(); // false
d.sign(); // 1

d.toNumber(); // 1.1
d.toBigInt(); // throws — not integral; round first
d.toRational().toString(); // '11/10'
JSON.stringify({ d }); // '{"d":"1.10"}'
`${d}`; // '1.10'
d + 1; // throws TypeError — a Decimal never silently becomes a float
+d; // throws, and so does '' + d; only `${d}` and String(d) work

Decimal.sum(['0.1', '0.2', '0.3']).toString(); // '0.6'
Decimal.max('1.5', 2, '0.7').toString(); // '2'
prices.sort(Decimal.compare); // comparator for sorting

Scale is retained through plus/minus (larger of the two) and times (sum of both). dividedBy without a scale returns the shortest exact decimal; '10.00' ÷ 4 is '2.5'. When a division does not terminate you have two honest options: round it (dividedBy(3, { scale, rounding })) or keep it exact as a fraction (d.toRational().dividedBy(3)).

Note that Decimal#round(places) takes places ≥ 0 (it sets the scale), while the float helper round(x, -2) rounds to hundreds.

Rational

A reduced fraction of two bigints; the denominator is always positive and zero is always 0/1.

ratio(6, 4).toString(); // '3/2'
ratio('4 1/2').toString(); // '9/2'   mixed numbers parse too
ratio('7/2').toMixed(); // { whole: 3n, fraction: 1/2 }
ratio('0.1(6)').toString(); // '1/6'
ratio(0.75).toString(); // '3/4'
ratio(decimal('0.5')); // 1/2

ratio(2, 3).times(ratio(3, 4)).toString(); // '1/2'
ratio(2, 3).dividedBy(2).toString(); // '1/3'
ratio(2, 3).inverse().toString(); // '3/2'
ratio(2, 3).pow(-2).toString(); // '9/4'

ratio(3, 8).toDecimal().toString(); // '0.375'
ratio(1, 3).toDecimal(); // throws RoundingNecessaryError
ratio(1, 3).toDecimal({ scale: 2, rounding: 'halfUp' }).toString(); // '0.33'
ratio(1, 3).toNumber(); // 0.3333333333333333

approximate(maxDenominator) finds the closest fraction with a bounded denominator — the "0.333 → ⅓" step every calculator needs.

Statistics

Inputs may be any iterable and are never mutated. Over plain numbers, sum uses compensated (Neumaier) summation, so sum([0.1, 0.2, 0.3]) is exactly 0.6. Give mean, median, minMax, variance or stddev an iterable of Decimal and you get an exact Decimal back (mean([decimal('0.1'), decimal('0.2')]) is 0.15; a mean that does not terminate needs { scale, rounding }, like division; stddev is correctly rounded at the scale you ask for). sum is number-only — Decimal.sum is the exact sum, and an empty list has no way to say which kind it wanted. variance/stddev default to the sample statistic (ddof: 1, like Python and Excel's STDEV); pass { ddof: 0 } for population. percentile(values, p) takes p in 0–100 and uses R‑7 linear interpolation (Excel PERCENTILE.INC, numpy's default). Empty input throws EmptyDataError; a non-finite element throws InvalidArgumentError.

import {
  linearRegression,
  mean,
  median,
  percentile,
  percentileRank,
  quantile,
  quartiles,
  stddev,
} from '@arki/math/stats';

mean([1, 2, 3, 4]); // 2.5
median([3, 1, 2]); // 2
percentile([1, 2, 3, 4], 25); // 1.75   p on 0–100
quantile([1, 2, 3, 4], 0.25); // 1.75   same thing, q on 0–1
quantile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.25, { method: 'weibull' }); // 2.75   Excel PERCENTILE.EXC
quartiles([1, 2, 3, 4, 5, 6, 7, 8], { method: 'tukey' }); // { q1: 2.5, q2: 4.5, q3: 6.5 }   box-plot hinges
percentileRank([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 7); // 65
stddev([2, 4, 4, 4, 5, 5, 7, 9], { ddof: 0 }); // 2
linearRegression([1, 2, 3], [2, 4, 6]).slope; // 2

quantile and percentile take { method } for all nine Hyndman–Fan definitions (R types 1–9, named as in NumPy: 'inverseCdf' is nearest rank, 'weibull' is Excel PERCENTILE.EXC, 'linear' the default). quartiles adds two hinge rules for odd counts: 'tukey' keeps the median in both halves (R fivenum), 'medianExcluded' leaves it out (TI-83, Moore & McCabe). pooledVariance/pooledStddev combine groups, given as raw samples or as { n, stddev } / { n, variance } summaries. polynomialRegression centers x before solving, so year-scale x (2015…2024) fits; predict evaluates in the centered basis, because the power-basis coefficients lose digits there. The linear fit also reports adjustedR2, residualStandardError and the slope/intercept standard errors; these are undefined when no degrees of freedom are left (a line through two points).

Inference uses exact critical values from distributions (Student t by default, method: 'z' for the normal). sampleSize rounds up.

import { confidenceInterval, polynomialRegression, sampleSize, zScore } from '@arki/math/stats';

confidenceInterval([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); // { mean: 5.5, lower: 3.33…, upper: 7.67…, … }  R t.test
sampleSize({ marginOfError: 0.03 }); // 1068   a ±3% poll at 95%
zScore(130, 100, 15); // 2
polynomialRegression([0, 1, 2, 3], [1, 2, 5, 10], 2).coefficients; // [1, 0, 1]  lowest degree first

Every fit (linearRegression, exponentialRegression, logarithmicRegression, powerRegression, polynomialRegression) returns its coefficients, r2 and predict(x). r2 is measured on the original y values, also for the fits that are solved on ln y.

Formatting

Built on Intl.NumberFormat, so every locale the runtime knows is supported with no tables shipped. Decimal values are passed to Intl as exact strings (ES2023 "NumberFormat v3") — a 30‑digit amount formats with all 30 digits. Formatter instances are cached, which is where the speed is.

import {
  createFormatter,
  formatCompact,
  formatCurrency,
  formatFileSize,
  formatNumber,
  formatPercent,
} from '@arki/math/format';

formatNumber(1234567.891); // '1,234,567.891'
formatNumber(2.5, { maximumFractionDigits: 0, rounding: 'halfEven' }); // '2'
formatCurrency(1234.5, 'EUR', { locale: 'de-DE' }); // '1.234,50 €'
formatCurrency(-5, 'USD', { accounting: true }); // '($5.00)'
formatCurrency(decimal('0.1').plus('0.2'), 'USD'); // '$0.30' — exact in, exact out
formatPercent(0.125); // '12.5%'  — input is a fraction, as in Intl
formatCompact(1230000); // '1.2M'
formatCompact(1230000, { display: 'long' }); // '1.2 million'
formatCompact(1_250_000, { currency: 'USD' }); // '$1.3M'
formatFileSize(1536); // '1.5 kB'
formatFileSize(1536, { system: 'iec' }); // '1.5 KiB'
formatFileSize(1536, { system: 'jedec' }); // '1.5 KB'  — powers of 1024, KB/MB/GB labels

const ro = createFormatter({ locale: 'ro-RO', currency: 'RON' });
ro.formatCurrency(25); // '25,00 RON'
ro.formatPercent(0.07); // '7 %'

There is no global "current locale": the default is en-US, every call takes { locale }, and createFormatter binds defaults for an app or a request without mutating shared state. That keeps SSR, tests, and concurrent requests honest. To format like toLocaleString() does, in the runtime's own locale, ask for it by name: { locale: 'default' } (exported as RUNTIME_LOCALE). A missing locale never means the runtime's.

Ordinals and number‑to‑words are not included — Intl has no style for them, and English suffix tables would be wrong everywhere else.

Parsing

Intl can format in any locale but cannot parse. parseNumber and parseDecimal derive the locale's digits, group, decimal and minus symbols from Intl.NumberFormat.formatToParts and read input the way that locale writes it.

import { parseDecimal, parseNumber } from '@arki/math/parse';

parseNumber('1,234.56'); // 1234.56
parseNumber('1.234,56', { locale: 'de-DE' }); // 1234.56
parseNumber('1 234,56', { locale: 'fr-FR' }); // 1234.56
parseNumber('−5', { locale: 'sv-SE' }); // -5   (U+2212 minus)
parseDecimal('1,234.56').toString(); // '1234.56'
parseNumber('1.234,56', { symbols: { decimal: ',', group: '.' } }); // 1234.56, no Intl needed
parseNumber('abc'); // throws NumberFormatError

The locale decides what a dot means: '1.234' is 1.234 in en-US and 1234 in de-DE.

Random

import { createRandom, randomInt, sample, shuffle } from '@arki/math/random';

randomInt(1, 6); // 1–6, both ends included, unbiased
shuffle([1, 2, 3, 4]); // a new array; the input is untouched
sample(['a', 'b', 'c', 'd'], 2); // two distinct picks

const rng = createRandom('level-7'); // seeded: the same sequence every run
rng.int(1, 100);
rng.gaussian(0, 1);
rng.pick(['rock', 'paper', 'scissors']);

Unseeded generators draw from crypto.getRandomValues when the runtime has it (Node 19+, Bun, Deno, browsers) and fall back to Math.random. Seeded generators use xoshiro128**, deterministic across runtimes.

Fluent

num() is the float-side counterpart of str() from @arki/string: immutable, every call returns a new wrapper, extract with .value().

import { num } from '@arki/math/fluent';

num(127.3).clamp(0, 100).roundTo(5).value(); // 100
num(1234.5678).round(2).formatCurrency('EUR'); // '€1,234.57'
num(0.1).toDecimal().plus('0.2').toString(); // '0.3'

Distributions

Each factory returns an immutable object with cdf, quantile and pdf or pmf, plus mean and variance (undefined where the moment does not exist, Infinity where it diverges). Parameters are validated up front; nothing returns NaN.

import { binomial, chiSquare, fisherF, noncentralT, normal, poisson, studentT } from '@arki/math/distributions';

normal().quantile(0.975); // 1.959963984540054
normal(100, 15).cdf(130); // 0.9772498680518208
studentT(10).quantile(0.975); // 2.2281388519862744
chiSquare(1).quantile(0.95); // 3.841458820694124
fisherF(3, 10).cdf(3.708265); // ≈ 0.95
noncentralT(10, 1).cdf(2); // 0.8076115625 — power analysis without jstat
binomial(10, 0.5).pmf(5); // 0.24609375
poisson(3).cdf(2); // 0.42319008112684353

For p-values, use the survival function instead of 1 - cdf(x) — the subtraction loses the tail: 1 - normal().cdf(9) is 0, while normal().sf(9) is 1.13e-19. Discrete quantile(p) is exactly "the smallest k with cdf(k) ≥ p".

The special functions underneath — erf, erfc, erfInv, gamma, gammaLn, beta, betaLn, regularizedGammaP, regularizedBeta — are exported too. Every one of them, and every distribution method, is measured against mpmath far into the tails; the bounds are in Accuracy.

Decimal math

Transcendental results are never exact, so these take an explicit scale (the same ScaleOptions as Decimal#dividedBy: fraction digits, not significant digits) and round correctly — the result is the true value rounded once at that scale, computed with guard digits and bigint arithmetic. Default rounding here is halfEven. sqrt, cbrt and nthRoot without options stay exact-or-throw like the rest of Decimal.

import { exp, ln, log10, PI, pow, sqrt } from '@arki/math/decimal-math';

sqrt('2.25').toString(); // '1.5'        exact
sqrt(2); // throws RoundingNecessaryError
sqrt(2, { scale: 30 }).toString(); // '1.414213562373095048801688724210'
exp(1, { scale: 20 }).toString(); // '2.71828182845904523536'
ln(10, { scale: 15 }).toString(); // '2.302585092994046'
pow(2, '0.5', { scale: 10 }).toString(); // '1.4142135624'
pow('1.5', 2).toString(); // '2.25'      integer exponents stay exact
PI(50).toString(); // '3.14159265358979323846264338327950288419716939937511'

Words

Numbers as words and ordinals, with real grammar per language. English is built in; other languages are separate subpaths so you bundle only what you use. Pass the rule object — there is no global locale.

import { enGB, formatOrdinal, spellNumber, spellOrdinal } from '@arki/math/words';
import { es } from '@arki/math/words/es';
import { fr } from '@arki/math/words/fr';
import { ro } from '@arki/math/words/ro';

spellNumber(1234); // 'one thousand two hundred thirty-four'
spellNumber(102, { locale: enGB }); // 'one hundred and two'
spellNumber('3.14'); // 'three point one four'
spellNumber(11, { until: 10 }); // '11'   Laravel-style thresholds
spellOrdinal(21); // 'twenty-first'
formatOrdinal(1001); // '1,001st'

spellNumber(21000, { locale: ro }); // 'douăzeci și una de mii'
spellNumber(2, { locale: ro, gender: 'feminine' }); // 'două'
spellOrdinal(2, { locale: ro, gender: 'feminine' }); // 'a doua'
formatOrdinal(21, { locale: ro }); // 'al 21-lea'
spellNumber(81, { locale: fr }); // 'quatre-vingt-un'
spellNumber(21000, { locale: es }); // 'veintiún mil'

Available: en (American), enGB (British "and"), ro, de, fr, es, it. Each locale object implements NumberWordsLocale; adding a language is one file against that contract. Locales are objects you import rather than Intl-style strings so that a bundle only carries the languages it uses.

Thresholds: a number is spelled when after < |value| ≤ until; outside that window it comes back as locale-formatted digits with its value intact (spellNumber('0.0001', { after: 1 }) → '0.0001'). Each locale has a magnitude limit (maxGroups, in base-1000 groups) beyond which it throws NumericRangeError before doing any work.

Money

Money lives in its own package, @arki/money. It builds on this one: same rounding modes, same error family, and it accepts Decimal and Rational as amounts.

Units

Twenty-two categories (length, mass, area, volume, temperature, time, speed, data, energy, pressure, angle, power, frequency, force, torque, fuel economy, data rate, electric current, illuminance, radioactivity, dynamic and kinematic viscosity, solid angle) with every factor stored as an exact rational, so convert(1, 'in', 'cm') is exactly 2.54 and convert(100, 'C', 'F') is exactly 212. convertExact returns the Rational; only conversions through π (radians) refuse to be exact. Fuel economy mixes distance-per-volume and volume-per-distance units: l/100km is the reciprocal of km/l, so convert(30, 'mpg', 'l/100km') is 7.84…, and converting 0 to or from l/100km throws.

import { convert, convertExact, formatUnit, joinUnits, splitUnits, unitsOf } from '@arki/math/units';

convert(5, 'km', 'mi'); // 3.1068559611866697
convert(1, 'GiB', 'GB'); // 1.073741824
convertExact(1, 'mi', 'km').toString(); // '25146/15625'
convert(1, 'm', 'kg'); // throws InvalidArgumentError: length → mass
formatUnit(5, 'km', { display: 'long' }); // '5 kilometers'
unitsOf('pressure'); // ['Pa', 'kPa', 'MPa', 'bar', 'atm', 'psi', 'mmHg', 'Torr']
splitUnits(67, 'in', ['ft', 'in']); // [{ unit: 'ft', value: 5 }, { unit: 'in', value: 7 }]
splitUnits(71.99, 'in', ['ft', 'in'], { scale: 0 }); // 6 ft 0 in   rounds once, then carries
joinUnits(
  [
    { unit: 'ft', value: 5 },
    { unit: 'in', value: 7 },
  ],
  'in',
); // 67

Finance

Spreadsheet-shaped (Excel PMT, NPV, IRR, FV, PV, EFFECT, NOMINAL) but exact on Decimal: rates are per-period fractions, every intermediate stays exact and the result is rounded once at the scale you ask for. payment returns a positive number for a positive loan.

import { amortizationSchedule, futureValue, netPresentValue, payment } from '@arki/math/finance';

payment(200_000, 0.005, 360, { scale: 2, rounding: 'halfEven' }).toString(); // '1199.10'
futureValue(1000, 0.05, 10).toString(); // exact: '1628.89462677744140625000'
netPresentValue(0.1, [100, 100, 100], { scale: 2 }).toString(); // '248.69'
amortizationSchedule(1000, 0.01, 12, { scale: 2 }).at(-1)?.balance.toString(); // '0.00'

futureValue and presentValue take { payment, timing } for annuities (Excel FV/PV with a payment, timing: 'begin' for type 1). Results are balances, so futureValue(pv, r, n, { payment }) is -FV(r, n, payment, pv).

@arki/math/finance/float has the same functions, names and sign rules on number, for what the exact layer cannot do: fractional or negative periods, horizons whose exact powers pass the digit limit, and tight loops. (1+r)^n goes through log1p/expm1, so tiny rates stay accurate and rate 0 is exact. periodsToReach (in both paths; it needs a logarithm, so it is always float) takes { payment } like Excel NPER.

import { futureValue, payment, periodsToReach } from '@arki/math/finance/float';

payment(200_000, 0.05 / 12, 360); // 1073.6432460242777
futureValue(1000, 0.05, 7.5); // 1441.8488753719537   fractional periods
periodsToReach(5000, 0, 0.18 / 12, { payment: -200 }); // 31.56…   months to pay off a card

Integers

Number theory with number and bigint overloads; the number form throws NumericRangeError the moment a result leaves the safe range, so switch to bigint for big values. isPrime is deterministic Miller–Rabin below 2⁶⁴; primeFactors adds Pollard's rho for large cofactors.

import { combinations, fibonacci, isPrime, modPow, primeFactors } from '@arki/math/integer';

combinations(52, 5); // 2598960
isPrime(9_007_199_254_740_881); // true
primeFactors(360); // [2, 2, 2, 3, 3, 5]
modPow(2n, 10n, 1000n); // 24n
fibonacci(90); // 2880067194370816120n
toBase(255, 16); // 'ff'
fromBase('zz', 36); // 1295n   always bigint, exact past 2^53

Bigint factorial, permutations and combinations allow results up to 100 000 digits (factorial up to n = 25 205; 10 000! takes about 1.5 ms). For a float factorial past the safe range, use gamma(n + 1) from distributions.

Geometry

Radians by default; pass { angleUnit: 'deg' } (any angle unit from units) to work in degrees. solveTriangles takes any three parts that fix a triangle and returns every triangle they describe: SSA can give 0, 1 or 2. solveTriangle returns the one triangle and throws when there are none or two. Areas use stable formulas (Kahan's Heron), so needle-thin triangles stay accurate.

import { circle, polygonArea, solveTriangle, solveTriangles, sphere } from '@arki/math/geometry';

solveTriangle({ a: 3, b: 4, c: 5 }).area; // 6
solveTriangles({ a: 7, b: 10, A: 30 }, { angleUnit: 'deg' }).length; // 2   the ambiguous case
circle(2).area; // 12.566370614359172
sphere(1).volume; // 4.1887902047863905
polygonArea([
  [0, 0],
  [4, 0],
  [4, 3],
]); // 6

Linear algebra

Dense float64 on plain arrays (number[], number[][]): no wrapper class, so values go to JSON and other libraries as they are, and results never share memory with inputs. solve, inverse and determinant use LU with partial pivoting. A matrix singular to working precision throws SingularMatrixError (pivot at or below n·ε·‖A‖∞; { tolerance } overrides). Import it from its subpath; the root does not re-export it.

import { determinant, inverse, solve } from '@arki/math/linear-algebra';

solve(
  [
    [2, 1, 1],
    [4, -6, 0],
    [-2, 7, 2],
  ],
  [5, -2, 9],
); // [1, 1, 2]
determinant([
  [2, 1, 1],
  [4, -6, 0],
  [-2, 7, 2],
]); // -16
inverse([
  [1, 2],
  [3, 4],
]); // [[-2, 1], [1.5, -0.5]]

Decompositions follow LAPACK's algorithms: pivoted Householder QR, Cholesky (NotPositiveDefiniteError when it fails), SVD (Golub–Kahan–Reinsch), and eigenvalues — symmetric by Householder tridiagonalization and implicit QL, general by balancing, Hessenberg reduction and Francis double-shift QR. leastSquares uses pivoted QR and returns the minimum-norm solution when A is rank-deficient. rank counts singular values above max(m, n)·ε·σ₁ (NumPy's rule); { method: 'elimination' } uses LU instead. conditionNumber is Infinity for an exactly singular matrix, like NumPy's cond. General eigenvalues come back as Complex, sorted like polynomialRoots.

import { eigenvalues, leastSquares, singularValues, symmetricEigenvalues } from '@arki/math/linear-algebra';

symmetricEigenvalues([
  [2, 1],
  [1, 2],
]); // [1.0000000000000002, 3]   ascending
eigenvalues([
  [0, -1],
  [1, 0],
]).map(String); // ['-i', 'i']   a rotation
singularValues([
  [3, 0],
  [4, 5],
]); // [6.708203932499369, 2.23606797749979]
leastSquares(
  [
    [1, 1],
    [1, 2],
    [1, 3],
  ],
  [1, 2, 2],
); // [0.666…, 0.5]   intercept, slope

Complex numbers

Immutable Complex with the same verbs as Decimal. Principal branches for sqrt, ln and pow; division by zero throws instead of giving NaN. toString() prints 3 + 4i and parses back exactly.

import { complex } from '@arki/math/complex';

complex(1, 1).pow(2).toString(); // '2i'
complex('3+4i').abs(); // 5
complex(-1).sqrt().toString(); // 'i'
complex(1).nthRoots(3).length; // 3
complex(1, 1).sin().toString(); // '1.2984575814159773 + 0.6349639147847361i'
complex(2).asin().toString(); // '1.5707963267948966 - 1.3169578969248166i'

sin, cos, tan, their inverses and the hyperbolic functions use principal branches (C99 Annex G). The library has no -0, so a point exactly on a branch cut takes Kahan's counter-clockwise rule, like sqrt and ln and like mpmath and Mathematica; C and NumPy pick the other side on half of the cuts. Measured within 3.1 ulp of a 700-digit reference from 1e-300 to 1e300.

Roots, integrals and derivatives

Every result carries an error estimate, and a method that misses its tolerance throws a ConvergenceError subclass holding its best estimate (.best) instead of returning a wrong number. A function that returns NaN or Infinity throws NumericRangeError naming the point. derivative and limit are numerical estimates, not proofs.

import { derivative, limit } from '@arki/math/differentiate';
import { integrate } from '@arki/math/integrate';
import { findRoot } from '@arki/math/roots';

findRoot(x => x ** 3 - 2 * x - 5, [2, 3]).root; // 2.094551481542327   Brent, bracket must change sign
integrate(x => Math.exp(-x * x), [0, Infinity]).value; // 0.886226925452758   √π/2
integrate(x => 1 / Math.sqrt(x), [0, 1], { method: 'tanhSinh' }).value; // 2   endpoint singularity
derivative(Math.sin, 0).value; // 1
limit(x => Math.sin(x) / x, 0).value; // 1

Polynomials

Coefficient arrays, highest degree first (like NumPy's polyval and roots): [1, -6, 11, -6] is x³ − 6x² + 11x − 6. Roots come back tagged, so you know whether you got real numbers or Complex values. quadraticRoots avoids cancellation; polynomialRoots handles any degree.

import { cubicRoots, evaluatePolynomial, polynomialRoots, quadraticRoots } from '@arki/math/polynomial';

evaluatePolynomial([1, -6, 11, -6], 4); // 6
quadraticRoots(1, -3, 2); // { kind: 'real', roots: [1, 2], discriminant: 1 }   b² − 4ac, error-compensated
quadraticRoots(1, 0, 1).kind; // 'complex'
cubicRoots(1, -6, 11, -6).roots; // [1, 2, 3]
polynomialRoots([1, 0, 0, 0, -1]).length; // 4

Errors

import { MathError, RoundingNecessaryError } from '@arki/math/errors';

try {
  decimal(1).dividedBy(3);
} catch (error) {
  if (error instanceof RoundingNecessaryError) {
    /* ask for a scale */
  }
  if (error instanceof MathError) {
    /* any failure from this package */
  }
}

| Error | When | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | NumberFormatError | a string is not a number in the accepted syntax | | InvalidArgumentError | reversed bounds, negative step, p out of range, … | | DivisionByZeroError | division by zero, zero to a negative power | | RoundingNecessaryError | an exact result needs rounding and no mode was given | | NumericRangeError | non-finite, unsafe integer, overflow or underflow | | EmptyDataError | a statistic of an empty (or too small) data set | | ResourceLimitError | more than 10 000 digits, or an exponent beyond ±10 000 | | UnsupportedFeatureError | the runtime's Intl lacks a feature the call relies on | | SingularMatrixError | solve/inverse met a matrix singular to working precision | | ConvergenceError | an iterative method did not converge (polynomialRoots, eigenvalues, SVD; RootConvergenceError, IntegrationConvergenceError, ExtrapolationConvergenceError carry .best) | | NotPositiveDefiniteError | choleskyDecompose met a matrix that is not positive definite |

Runtime support

  • Node 22+, Bun, Deno, browsers: everything, verified by the test suite and a smoke run of the built package in each runtime (bun run test:runtimes).
  • React Native (Hermes V1): everything runs; bun run test:runtimes checks it when HERMES_BIN points at a Hermes binary. Two Intl gaps shape formatting and parsing:
    • Hermes formats decimal text through a double. Decimal values are rounded exactly first, so anything up to 15 shown digits (every normal amount) prints exactly; more throws UnsupportedFeatureError instead of printing a wrong digit. Compact notation accepts whole numbers only.
    • Hermes on iOS has no formatToParts, so locale parsing throws UnsupportedFeatureError; pass symbols to parse without Intl.
    • A non-default rounding mode throws UnsupportedFeatureError.
  • Pure ESM, no runtime-specific globals; the lint config rejects any node:*/bun:* import or process/Buffer use in library code.

Limits

Every Decimal and Rational — input or result — has at most 10 000 digits (integer digits plus scale) and an exponent magnitude of at most 10 000; anything larger throws ResourceLimitError, early from an estimate and exactly on the result. Internal guard digits do not count, so PI(9999) is the largest π, and every value the library returns parses back from its own toString(). Bigint factorial, permutations and combinations allow results up to 100 000 digits; fibonacci and fromBase keep 10 000. These are resource guards against hostile input, not precision settings — within them, arithmetic is exact.

Accuracy

Every row below is checked by a test. The tests compare against independent reference data in test/reference/. That folder is for development and is not published. The references are mpmath (arbitrary precision) for decimal-math, the special functions and the distributions, and the NIST StRD certified values for statistics and regression. Seeded generator scripts rebuild the data.

The guarantees come in three kinds:

  • Exact. No rounding happens unless you ask for it: Decimal and Rational arithmetic, convertExact, exact finance, and integer functions.
  • Correctly rounded. The result is the true value rounded once, in the mode you chose. The tests compare digit for digit, and test/reference/ERROR-ANALYSIS.md has the proof.
  • Float bounds. These are the largest error seen over every reference point, tails included. They are measured, not proven. A change that exceeds one fails the build.

Decimal math (@arki/math/decimal-math)

Each case runs in all 8 rounding modes, at scales from 0 to 100. In unnecessary mode, an inexact value must throw.

  • Near-tie cases sit within 10^-(scale+14) to 10^-(scale+90) of a rounding boundary. They force the Ziv loop to retry with 20, 40, 80 and 160 guard digits.
  • Exact cases are roots that land exactly on a tie, and logarithms or powers with exact results.

| Function | Guarantee | Evidence: cases (random / near-tie / exact) | | ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- | | sqrt | correctly rounded; exact or RoundingNecessaryError without options | 120 / 12 / 12 | | cbrt | correctly rounded; negative input gives a negative root | 80 / 12 / 12 | | nthRoot (n 4–12) | correctly rounded | 80 / 12 / 12 | | exp | correctly rounded | 120 / 32 / — | | ln | correctly rounded | 120 / 32 / — | | log10, log2 | correctly rounded; exact results detected | 159 / 64 / 24 | | log (any base) | correctly rounded; exact results detected | 60 / — / 24 | | pow (non-integer) | correctly rounded; exact results detected | 114 / 32 / 30 | | PI, E | correctly rounded (half-even) | Machin and Taylor kernels audited in the error analysis |

Special functions and distributions (@arki/math/distributions)

The error is relative: |got − want| / |want|. The noncentral t is the exception, measured in absolute error. One ulp is about 1.1e-16 to 2.2e-16. The reference values go down to about 1e-300. Quantiles are tested at p from 1e-300 up to 1 − 1e-15.

| Function | Max error | Points | Range covered | | ------------------------------------ | ------------------------------------------------ | -----: | -------------------------------------------------------- | | erf | 4e-16 (2 ulp) | 180 | |x| from 1e-300 to 6 | | erfc | 5e-16 (3 ulp) | 149 | x from −6 to 26.5 (values down to 6e-296) | | erfInv | 5e-16 (2 ulp) | 186 | p from 1e-300 to 1 − 1e-15 | | gamma | 3e-13 | 75 | x from 1e-300 to 171.6, and negative non-integers | | gammaLn | 1e-13 | 180 | x from 1e-300 to 1e300 | | beta, betaLn | 2e-12, 5e-13 | 336 | a, b from 1e-3 to 1e3 | | regularizedGammaP | 1e-12 | 271 | a from 0.01 to 1000, x from a/1000 to 10a | | regularizedBeta | 2e-11 | 800 | a, b from 0.1 to 1000, x from 0.001 to 0.999 | | normal cdf, sf / pdf | 3e-13 / 2e-13 | 570 | z from −37 to 37 | | normal quantile | 5e-16 (2 ulp) | 68 | p from 1e-300 to 1 − 1e-15 | | studentT cdf, sf / pdf | 5e-12 / 1e-12 | 1 560 | df from 1 to 1000, |t| up to 1e8 | | studentT quantile | 3e-12 | 224 | p from 1e-100 to 1 − 1e-12 | | chiSquare cdf, sf, pdf | 2e-12 | 836 | df from 1 to 1000, x from df/10⁴ to 50·df | | chiSquare quantile | 2e-12 | 224 | p from 1e-100 to 1 − 1e-12 | | fisherF cdf, sf, pdf | 3e-13 | 693 | (df1, df2) from (1, 1) to (100, 100), x from 1e-4 to 1e4 | | fisherF quantile | 3e-12 | 175 | p from 1e-60 to 1 − 1e-10 | | noncentralT cdf | 2e-14 absolute | 174 | df from 5 to 50, ncp from −2 to 10 | | exponential cdf, sf, pdf, quantile | correctly rounded at every point (bound 2.3e-16) | 211 | x from 1e-10 to 316; p down to 1e-300 | | binomial pmf, cdf, sf | 2e-12 | 1 022 | n up to 1000, p from 0.01 to 0.9, the whole support | | poisson pmf, cdf, sf | 8e-12 | 1 558 | λ from 0.5 to 1000, out to λ + 40√λ + 40 | | binomial, poisson quantile | exact (the smallest k with cdf(k) ≥ p) | 360 | random p |

The noncentral t (AS 243) is accurate in absolute terms. Where its cdf is at least 1e-3, the relative error is at most 1.2e-12. Far down the lower tail, the relative error grows: 4e-7 where the cdf is 1e-8. For comparison, scipy (Boost) is off by up to 1.7e-8 relative on the same points.

Statistics and regression (NIST StRD)

The figures are digits of agreement with the certified values, out of the 15 that NIST certifies. Higher is better. The measure is the log relative error, LRE = −log₁₀(relative error).

| Dataset (difficulty) | Function | Digits on number | On Decimal | | -------------------------------------------------------- | ---------------------------------------------------------------------------- | -----------------: | ---------------------------- | | PiDigits, Lottery, Lew, NumAcc1 (lower) | mean, stddev | 15, 15 | equal to the certified value | | Mavro, Michelso (lower) | mean / stddev | 15 / ≥ 13 | equal to the certified value | | NumAcc2 (average) | mean / stddev | 15 / ≥ 14.5 | equal to the certified value | | NumAcc3 (average), NumAcc4 (higher) | mean / stddev | 15 / ≥ 9, ≥ 8 | equal to the certified value | | Norris (lower) | linearRegression: slope, intercept, their standard errors, residual SD, r² | ≥ 12.5 | — | | Pontius (lower, quadratic) | polynomialRegression | ≥ 11.5 | — | | Filip (higher, degree 10) | polynomialRegression | ≥ 13.5 | — | | Wampler1–5 (higher, degree 5) | polynomialRegression | ≥ 9, 12, 9, 9, 7.5 | — | | Longley (higher, 6 predictors); NoInt1, NoInt2 (average) | leastSquares | ≥ 10.5; ≥ 14 | — |

On Decimal input, mean is exact and stddev is correctly rounded. Both reproduce every certified value digit for digit.

On number input, NumAcc3 and NumAcc4 are limited by the data, not by the algorithm: values such as 10000000.2 are not exact doubles. Against the exact standard deviation of the binary inputs, stddev keeps at least 14 digits on every dataset.

The polynomial and least-squares rows hold on both V8 and JavaScriptCore.

Benchmarks

Speed of @arki/math against the libraries most people reach for: decimal.js, big.js and bignumber.js for decimal arithmetic; fraction.js for exact fractions; simple-statistics and jstat for descriptive statistics; jstat for probability distributions; simple-statistics and regression (regression-js) for curve fitting; ml-matrix and mathjs for linear algebra; complex.js and mathjs for complex numbers; mathjs for polynomial roots. Each table only ranks libraries that returned the same result on every checked input. The published tables come from a publish run on an idle machine and keep every case, including the ones where @arki/math is slower.

Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.

Decimal plus (1234.5678 + 98.76)

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math | 102.0 ns | fastest | | decimal.js | 388.5 ns | 3.81× | | big.js | 386.0 ns | 3.78× | | bignumber.js | 268.1 ns | 2.63× |

Decimal times

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math | 98.7 ns | fastest | | decimal.js | 664.9 ns | 6.73× | | big.js | 332.4 ns | 3.37× | | bignumber.js | 502.8 ns | 5.09× |

Decimal divide, 10 places, half-even

Every library rounds the quotient to 10 decimal places, half-even. The other libraries divide at a working precision first and round after; @arki/math rounds once, exactly.

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math dividedBy({ scale, rounding }) | 673.5 ns | fastest | | decimal.js div + toDecimalPlaces | 11.21 µs | 16.65× | | big.js div + round | 4.12 µs | 6.11× | | bignumber.js dividedBy + decimalPlaces | 13.34 µs | 19.81× |

Decimal parse a string

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math decimal() | 476.1 ns | fastest | | decimal.js | 712.0 ns | 1.50× | | big.js | 699.4 ns | 1.47× | | bignumber.js | 911.6 ns | 1.91× |

Decimal toString (30 integer digits, 9 decimals)

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math | 336.6 ns | 1.00× | | decimal.js | 460.7 ns | 1.37× | | big.js | 2.06 µs | 6.13× | | bignumber.js | 336.3 ns | fastest |

Rational plus (355/113 + 22/7)

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math ratio | 235.6 ns | fastest | | fraction.js | 441.5 ns | 1.87× |

Rational times

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math ratio | 619.0 ns | 1.77× | | fraction.js | 349.4 ns | fastest |

Mean of 1,000 numbers

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math | 34.69 µs | 12.80× | | simple-statistics | 2.71 µs | fastest |

Standard deviation of 1,000 numbers (population)

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math | 14.85 µs | 5.85× | | simple-statistics standardDeviation | 2.54 µs | fastest |

90th percentile of 1,000 numbers

Both interpolate linearly between ranks, so results agree to rounding.

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math percentile | 304.59 µs | 61.28× | | simple-statistics quantile | 4.97 µs | fastest |

Normal distribution cdf at 1.96

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math normal().cdf | 470.7 ns | 2.34× | | jstat normal.cdf | 201.3 ns | fastest |

Normal distribution quantile at 0.975

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math normal().quantile | 1.28 µs | fastest | | jstat normal.inv | 2.39 µs | 1.88× |

Student t (10 degrees of freedom) quantile at 0.975

@arki/math solves the quantile until |cdf(x) − p| ≤ 1e-12 and rejects bad input; jstat uses a looser fixed approximation with no input checks.

| Library | Time / op | vs fastest | | --- | ---: | ---: | | @arki/math studentT(10).quantile | 4.90 µs | 5.03× | | jstat studentt.inv | 974.2 ns | fastest |

Read the rows with the contracts in mind:

  • @arki/math Decimal is exact: it stores a bigint plus a scale and never rounds unless you ask. Division throws when the result does not terminate, so every divide case below passes an explicit { scale, rounding }.
  • decimal.js and bignumber.js round to a working precision on every operation (here 40 significant digits for decimal.js, 20 decimal places for bignumber.js and big.js). They are fast because they give up exactness; @arki/math gives up some speed to keep it.
  • Rational is exact and always reduced to lowest terms, as in fraction.js.
  • Statistics functions in @arki/math check every element is a finite number, copy the input, sum with Neumaier compensation (so the total is accurate) and sort the full array for percentiles. simple-statistics skips the checks and the copy, uses plain summation and selects the percentile without a full sort. That is why they are much faster on 1,000 numbers: @arki/math trades speed for validated input and accurate sums.

Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.

License

MIT