arbitrary-numbers
v1.0.2
Published
Arbitrary-magnitude arithmetic for idle games and simulations, with fused operations, formula pipelines, and pluggable number formatting.
Maintainers
Readme
arbitrary-numbers fills a specific gap: JavaScript's Number type silently loses precision above Number.MAX_SAFE_INTEGER, and BigInt can't represent decimals, so working with extremely large or small magnitudes often requires manual scaling into very large integers.
Numbers are stored as a normalized coefficient × 10^exponent pair. That makes arithmetic across wildly different scales fast and predictable — exactly what idle games and simulations need when values span from 1 to 10^300 in the same loop.
- Immutable by default — every operation returns a new instance, no surprise mutations
- Fused operations (
mulAdd,subMul, ...) — reduce allocations in hot loops - Formula pipelines — define an expression once, apply it to any number of values
- Pluggable display — swap between scientific, unit (K/M/B/T), and letter notation without touching game logic
- Zero dependencies — nothing to audit, nothing to break
Install
npm install arbitrary-numbersRequires TypeScript "strict": true.
Quick start
import { an, chain, formula, unitNotation } from "arbitrary-numbers";
// JavaScript range limits
const jsHuge = Number("1e500"); // Infinity
const jsTiny = Number("1e-500"); // 0
// Arbitrary range in both directions
const huge = an(1, 500);
const tiny = an(1, -500);
// One-off pipeline with chain(): (6.2e15 - 8.5e13) * 0.75
const damage = chain(an(6.2, 15))
.subMul(an(8.5, 13), an(7.5, -1))
.floor()
.done();
// Reusable per-tick formula: gold = (gold * 1.08) + 2_500_000
const tick = formula("tick").mulAdd(an(1.08), an(2.5, 6));
let gold = an(7.5, 12);
for (let i = 0; i < 3; i += 1) {
gold = tick.apply(gold);
}
console.log("=== Range limits (JS vs arbitrary-numbers) ===");
console.log(`JS Number('1e500') -> ${jsHuge}`);
console.log(`AN an(1, 500) -> ${huge.toString()}`);
console.log(`JS Number('1e-500') -> ${jsTiny}`);
console.log(`AN an(1, -500) -> ${tiny.toString()}`);
console.log("");
console.log("=== Game math helpers ===");
console.log(`Damage (chain + fused subMul) -> ${damage.toString(unitNotation)}`);
console.log(`Gold after 3 ticks (formula) -> ${gold.toString(unitNotation)}`);Example output when running this in a repository checkout (for example with npx tsx examples/quickstart.ts):
=== Range limits (JS vs arbitrary-numbers) ===
JS Number('1e500') -> Infinity
AN an(1, 500) -> 1.00e+500
JS Number('1e-500') -> 0
AN an(1, -500) -> 1.00e-500
=== Game math helpers ===
Damage (chain + fused subMul) -> 4.59 Qa
Gold after 3 ticks (formula) -> 9.45 TTable of contents
- Install
- Quick start
- Table of contents
- Creating numbers
- Arithmetic
- Fused operations
- Fluent builder -
chain() - Reusable formulas -
formula() - Comparison and predicates
- Rounding and math
- Display and formatting
- Precision control
- Errors
- Utilities
- Writing a custom plugin
- Idle game example
- Performance
Creating numbers
import { ArbitraryNumber, an } from "arbitrary-numbers";
// From a coefficient and exponent
new ArbitraryNumber(1.5, 3) // 1,500 { coefficient: 1.5, exponent: 3 }
new ArbitraryNumber(15, 3) // 15,000 -> { coefficient: 1.5, exponent: 4 } (normalised)
new ArbitraryNumber(0, 99) // Zero -> { coefficient: 0, exponent: 0 }
// From a plain JS number
ArbitraryNumber.from(1_500_000) // { coefficient: 1.5, exponent: 6 }
ArbitraryNumber.from(0.003) // { coefficient: 3, exponent: -3 }
// Shorthand
an(1.5, 6) // same as new ArbitraryNumber(1.5, 6)
an.from(1_500) // same as ArbitraryNumber.from(1500)
// Static constants
ArbitraryNumber.Zero // 0
ArbitraryNumber.One // 1
ArbitraryNumber.Ten // 10Inputs must be finite. NaN, Infinity, and -Infinity throw ArbitraryNumberInputError:
ArbitraryNumber.from(Infinity) // throws ArbitraryNumberInputError { value: Infinity }
new ArbitraryNumber(NaN, 0) // throws ArbitraryNumberInputError { value: NaN }
new ArbitraryNumber(1, Infinity) // throws ArbitraryNumberInputError { value: Infinity }Arithmetic
All methods return a new ArbitraryNumber. Instances are immutable.
const a = an(3, 6); // 3,000,000
const b = an(1, 3); // 1,000
a.add(b) // 3,001,000
a.sub(b) // 2,999,000
a.mul(b) // 3,000,000,000
a.div(b) // 3,000
a.pow(2) // 9 * 10^12
a.negate() // -3,000,000
a.abs() // 3,000,000Fused operations
Fused methods compute a two-step expression in one normalisation pass, saving one intermediate allocation per call. Use them in per-tick update loops.
// (gold * rate) + bonus in one pass, ~1.5x faster than chained
gold = gold.mulAdd(prestigeRate, prestigeBonus);
// Other fused pairs
base.addMul(bonus, multiplier); // (base + bonus) * multiplier
income.mulSub(rate, upkeep); // (income * rate) - upkeep
raw.subMul(reduction, boost); // (raw - reduction) * boost
damage.divAdd(speed, flat); // (damage / speed) + flat
// Sum an array in one pass, ~9x faster than .reduce((a, b) => a.add(b))
const total = ArbitraryNumber.sumArray(incomeSources);Fluent builder - chain()
chain() wraps an ArbitraryNumber in a thin accumulator. Each method mutates the accumulated value and returns this. No expression tree, no deferred execution.
import { chain } from "arbitrary-numbers";
const damage = chain(base)
.subMul(armour, mitigation) // (base - armour) * mitigation, fused
.add(flat)
.floor()
.done(); // returns the ArbitraryNumber resultAll fused ops are available on the builder, so complex formulas do not sacrifice performance.
Available methods: add, sub, mul, div, pow, mulAdd, addMul, mulSub, subMul, divAdd, abs, neg, sqrt, floor, ceil, round, done.
Reusable formulas - formula()
formula() builds a deferred pipeline. Unlike chain(), a formula stores its operations and runs them only when apply() is called, so the same formula can be applied to any number of values.
import { formula, an } from "arbitrary-numbers";
const armorReduction = formula("Armor Reduction")
.subMul(armor, an(7.5, -1)) // (base - armor) * 0.75
.floor();
const physDamage = armorReduction.apply(physBase);
const magDamage = armorReduction.apply(magBase);Each step returns a new AnFormula, leaving the original unchanged. Branching is safe:
const base = formula().mul(an(2));
const withFloor = base.floor(); // new formula, base is unchanged
const withCeil = base.ceil(); // another branch from the same baseCompose two formulas in sequence with then():
const critBonus = formula("Crit Bonus").mul(an(1.5)).ceil();
const full = armorReduction.then(critBonus);
const result = full.apply(baseDamage);Available methods: add, sub, mul, div, pow, mulAdd, addMul, mulSub, subMul, divAdd, abs, neg, sqrt, floor, ceil, round, then, named, apply.
chain() vs formula()
| | chain(value) | formula(name?) |
|---|---|---|
| Execution | Immediate | Deferred, runs on apply() |
| Input | Fixed at construction | Provided at apply() |
| Reusable | No, one-shot | Yes, any number of times |
| Composable | No | Yes, via then() |
| Builder style | Stateful accumulator | Immutable, each step returns a new instance |
| Terminal | .done() | .apply(value) |
Comparison and predicates
const a = an(1, 4); // 10,000
const b = an(9, 3); // 9,000
a.compareTo(b) // 1 (compatible with Array.sort)
a.greaterThan(b) // true
a.lessThan(b) // false
a.greaterThanOrEqual(b) // true
a.lessThanOrEqual(b) // false
a.equals(b) // false
a.isZero() // false
a.isPositive() // true
a.isNegative() // false
a.isInteger() // true
a.sign() // 1 (-1 | 0 | 1)
ArbitraryNumber.min(a, b) // b (9,000)
ArbitraryNumber.max(a, b) // a (10,000)
ArbitraryNumber.clamp(an(5, 5), a, an(1, 5)) // an(1, 5) (clamped to max)
ArbitraryNumber.lerp(a, b, 0.5) // 9,500Rounding and math
const n = an(1.75, 0); // 1.75
n.floor() // 1
n.ceil() // 2
n.round() // 2
an(4, 0).sqrt() // 2 (1.18x faster than .pow(0.5))
an(1, 4).sqrt() // 100
an(-4, 0).sqrt() // throws ArbitraryNumberDomainError
an(1, 3).log10() // 3
an(1.5, 3).log10() // 3.176...
ArbitraryNumber.Zero.log10() // throws ArbitraryNumberDomainError
an(1.5, 3).toNumber() // 1500
an(1, 400).toNumber() // Infinity (exponent beyond float64 range)Display and formatting
toString(plugin?, decimals?) accepts any NotationPlugin. Three plugins are included.
scientificNotation (default)
import { scientificNotation } from "arbitrary-numbers";
an(1.5, 3).toString() // "1.50e+3"
an(1.5, 3).toString(scientificNotation, 4) // "1.5000e+3"
an(1.5, 0).toString() // "1.50"unitNotation - K, M, B, T...
import { unitNotation, UnitNotation, COMPACT_UNITS, letterNotation } from "arbitrary-numbers";
an(1.5, 3).toString(unitNotation) // "1.50 K"
an(3.2, 6).toString(unitNotation) // "3.20 M"
an(1.0, 9).toString(unitNotation) // "1.00 B"
// Custom unit list with a fallback for values beyond the list
const custom = new UnitNotation({ units: COMPACT_UNITS, fallback: letterNotation });AlphabetNotation - a, b, c... aa, ab...
import { letterNotation, AlphabetNotation, alphabetSuffix } from "arbitrary-numbers";
an(1.5, 3).toString(letterNotation) // "1.50a"
an(1.5, 6).toString(letterNotation) // "1.50b"
an(1.5, 78).toString(letterNotation) // "1.50z"
an(1.5, 81).toString(letterNotation) // "1.50aa"Suffixes never run out: a-z, then aa-zz, then aaa, and so on.
Pass a custom alphabet for any suffix sequence:
const excelNotation = new AlphabetNotation({ alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" });
an(1.5, 3).toString(excelNotation) // "1.50A"
an(1.5, 78).toString(excelNotation) // "1.50Z"
an(1.5, 81).toString(excelNotation) // "1.50AA"alphabetSuffix(tier, alphabet?) exposes the suffix algorithm as a standalone function:
import { alphabetSuffix } from "arbitrary-numbers";
alphabetSuffix(1) // "a"
alphabetSuffix(27) // "aa"
alphabetSuffix(27, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") // "AA"Precision control
When two numbers differ in exponent by more than PrecisionCutoff (default 15), the smaller operand is silently discarded because its contribution is below floating-point resolution:
const huge = an(1, 20); // 10^20
const tiny = an(1, 3); // 1,000
huge.add(tiny) // returns huge unchangedOverride globally or for a single scoped block:
ArbitraryNumber.PrecisionCutoff = 50; // global
// Scoped - PrecisionCutoff is restored after fn, even on throw
const result = ArbitraryNumber.withPrecision(50, () => a.add(b));Errors
All errors thrown by the library extend ArbitraryNumberError, so you can distinguish them from your own errors.
import {
ArbitraryNumberError,
ArbitraryNumberInputError,
ArbitraryNumberDomainError,
} from "arbitrary-numbers";
try {
an(1).div(an(0));
} catch (e) {
if (e instanceof ArbitraryNumberDomainError) {
console.log(e.context); // { dividend: 1 }
}
}| Class | Thrown when | Extra property |
|---|---|---|
| ArbitraryNumberInputError | Non-finite input to a constructor or factory | .value: number |
| ArbitraryNumberDomainError | Mathematically undefined operation | .context: Record<string, number> |
Utilities
ArbitraryNumberOps - mixed number | ArbitraryNumber input
import { ArbitraryNumberOps as ops } from "arbitrary-numbers";
ops.from(1_500_000) // { coefficient: 1.5, exponent: 6 }
ops.add(1500, an(2, 3)) // 3,500
ops.mul(an(2, 0), 5) // 10
ops.compare(5000, an(1, 4)) // -1 (5000 < 10,000)
ops.clamp(500, 1000, 2000) // 1,000ArbitraryNumberGuard - type guards
import { ArbitraryNumberGuard as guard } from "arbitrary-numbers";
guard.isArbitraryNumber(value) // true when value instanceof ArbitraryNumber
guard.isNormalizedNumber(value) // true when value has numeric coefficient and exponent
guard.isZero(value) // true when value is ArbitraryNumber with coefficient 0ArbitraryNumberHelpers - game and simulation patterns
import { ArbitraryNumberHelpers as helpers } from "arbitrary-numbers";
helpers.meetsOrExceeds(gold, upgradeCost) // true when gold >= upgradeCost
helpers.wholeMultipleCount(gold, upgradeCost) // how many upgrades can you afford?
helpers.subtractWithFloor(health, damage) // max(health - damage, 0)
helpers.subtractWithFloor(health, damage, minHealth) // max(health - damage, minHealth)All helpers accept number | ArbitraryNumber as input.
Writing a custom plugin
Any object with a format(coefficient, exponent, decimals) method is a valid NotationPlugin:
import type { NotationPlugin } from "arbitrary-numbers";
const emojiNotation: NotationPlugin = {
format(coefficient, exponent, decimals) {
const tiers = ["", "K", "M", "B", "T", "Qa", "Qi"];
const tier = Math.floor(exponent / 3);
const display = coefficient * 10 ** (exponent - tier * 3);
return `${display.toFixed(decimals)}${tiers[tier] ?? `e+${tier * 3}`}`;
},
};
an(1.5, 3).toString(emojiNotation) // "1.50K"
an(1.5, 6).toString(emojiNotation) // "1.50M"For tier-based suffix patterns, extend SuffixNotationBase, which handles all coefficient and remainder math:
import { SuffixNotationBase } from "arbitrary-numbers";
class TierNotation extends SuffixNotationBase {
private static readonly TIERS = ["", "K", "M", "B", "T", "Qa", "Qi"];
getSuffix(tier: number): string {
return TierNotation.TIERS[tier] ?? `e+${tier * 3}`;
}
}
an(3.2, 6).toString(new TierNotation({ separator: " " })) // "3.20 M"Idle game example
A self-contained simulation showing hyper-growth, fused ops, helpers, and where plain JS number overflows while ArbitraryNumber keeps working.
import {
an, chain,
UnitNotation,
CLASSIC_UNITS,
letterNotation,
ArbitraryNumberHelpers as helpers,
} from "arbitrary-numbers";
import type { ArbitraryNumber } from "arbitrary-numbers";
let gold = an(5, 6); // 5,000,000
let gps = an(2, 5); // 200,000 per tick
let reactorCost = an(1, 9);
let reactors = 0;
const display = new UnitNotation({
units: CLASSIC_UNITS,
fallback: letterNotation,
});
function fmt(value: ArbitraryNumber, decimals = 2): string {
return value.toString(display, decimals);
}
function snapshot(tick: number): void {
console.log(
`[t=${String(tick).padStart(4)}] SNAPSHOT `
+ `gold=${fmt(gold, 2).padStart(12)} gps=${fmt(gps, 2).padStart(12)}`,
);
}
console.log("=== Hyper-growth idle loop (720 ticks) ===");
console.log(`start gold=${fmt(gold)} gps=${fmt(gps)} reactorCost=${fmt(reactorCost)}`);
for (let t = 1; t <= 720; t += 1) {
// Core growth: gold = (gold * 1.12) + gps
gold = gold.mulAdd(an(1.12), gps);
if (t % 60 === 0 && helpers.meetsOrExceeds(gold, reactorCost)) {
const before = gps;
gold = gold.sub(reactorCost);
gps = chain(gps).mul(an(1, 25)).floor().done();
reactorCost = reactorCost.mul(an(8));
reactors += 1;
console.log(
`[t=${String(t).padStart(4)}] REACTOR #${String(reactors).padStart(2)} `
+ `gps ${fmt(before)} -> ${fmt(gps)} `
+ `nextCost=${fmt(reactorCost)}`,
);
}
if (t === 240 || t === 480) {
const before = gps;
gps = chain(gps)
.mul(an(1, 4))
.add(an(7.5, 6))
.floor()
.done();
console.log(`[t=${String(t).padStart(4)}] PRESTIGE gps ${fmt(before)} -> ${fmt(gps)}`);
}
if (t % 120 === 0) {
snapshot(t);
}
}
console.log("\n=== Final scale check ===");
console.log(`reactors bought: ${reactors}`);
console.log(`final gold (unit+letter): ${fmt(gold)}`);
console.log(`final gps (unit+letter): ${fmt(gps)}`);
console.log(`final gold as JS Number: ${gold.toNumber()}`);
console.log(`final gps as JS Number : ${gps.toNumber()}`);
console.log("If JS shows Infinity while unit+letter output stays finite, the library is doing its job.");Output:
=== Hyper-growth idle loop (720 ticks) ===
start gold=5.00 M gps=200.00 K reactorCost=1.00 B
[t= 60] REACTOR # 1 gps 200.00 K -> 2.00 No nextCost=8.00 B
[t= 120] REACTOR # 2 gps 2.00 No -> 20.00 SpDc nextCost=64.00 B
[t= 120] SNAPSHOT gold= 14.94 Dc gps= 20.00 SpDc
[t= 180] REACTOR # 3 gps 20.00 SpDc -> 200.00 QiVg nextCost=512.00 B
[t= 240] REACTOR # 4 gps 200.00 QiVg -> 2.00 ai nextCost=4.10 T
[t= 240] PRESTIGE gps 2.00 ai -> 20.00 aj
[t= 240] SNAPSHOT gold= 1.49 SpVg gps= 20.00 aj
[t= 300] REACTOR # 5 gps 20.00 aj -> 200.00 ar nextCost=32.77 T
[t= 360] REACTOR # 6 gps 200.00 ar -> 2.00 ba nextCost=262.14 T
[t= 360] SNAPSHOT gold= 1.49 at gps= 2.00 ba
[t= 420] REACTOR # 7 gps 2.00 ba -> 20.00 bi nextCost=2.10 Qa
[t= 480] REACTOR # 8 gps 20.00 bi -> 200.00 bq nextCost=16.78 Qa
[t= 480] PRESTIGE gps 200.00 bq -> 2.00 bs
[t= 480] SNAPSHOT gold= 149.43 bj gps= 2.00 bs
[t= 540] REACTOR # 9 gps 2.00 bs -> 20.00 ca nextCost=134.22 Qa
[t= 600] REACTOR #10 gps 20.00 ca -> 200.00 ci nextCost=1.07 Qi
[t= 600] SNAPSHOT gold= 149.43 cb gps= 200.00 ci
[t= 660] REACTOR #11 gps 200.00 ci -> 2.00 cr nextCost=8.59 Qi
[t= 720] REACTOR #12 gps 2.00 cr -> 20.00 cz nextCost=68.72 Qi
[t= 720] SNAPSHOT gold= 14.94 cs gps= 20.00 cz
=== Final scale check ===
reactors bought: 12
final gold (unit+letter): 14.94 cs
final gps (unit+letter): 20.00 cz
final gold as JS Number: 1.494328222485101e+292
final gps as JS Number : Infinity
If JS shows Infinity while unit+letter output stays finite, the library is doing its job.Performance
Benchmarks are in benchmarks/. Competitor comparison: benchmarks/COMPETITOR_BENCHMARKS.md.
Quick reference (Node 22.16, Intel i5-13600KF):
| Operation | Time |
|---|---|
| add / sub (typical) | ~20-28 ns |
| mul / div | ~10-11 ns |
| Fused ops (mulAdd, mulSub, ...) | ~27-29 ns, 1.5-1.6x faster than chained |
| sumArray(50 items) | ~200 ns, 8.4-8.7x faster than .reduce |
| compareTo (same exponent) | ~0.6 ns |
| sqrt() | ~10 ns |
| pow(0.5) | ~7 ns |
