The problem · See it work · Install · Quick start · Footguns · Reference
npm install @ignromanov/decimal1280.1 + 0.2; // 0.30000000000000004Binary floating point can't represent most decimal fractions exactly, which makes it unsafe for money, invoicing, or any calculation where "close enough" isn't good enough.
If you've reached for decimal.js, big.js, or bignumber.js before, you already know the fix:
do decimal math in a dedicated type, never in binary floats. What's different here is how and
to what standard:
- It implements IEEE 754-2019 Decimal128 — the same fixed-precision decimal model used by
MongoDB's
Decimal128BSON type, SQLDECIMAL, and the (Stage 1) TC39Decimalproposal — with well-defined rounding, overflow, and special-value (NaN/±Infinity) behavior, instead of each library inventing its own precision rules. - It's TS-first (branded types, discriminated errors) and per-operation tree-shakeable — a single imported op bundles to ~3.8 KB, because there's no monolithic class to drag in.
Every value carries up to 34 significant digits — enough for any real-world financial or scientific use.
import { from, add, divide, toFixed } from "@ignromanov/decimal128";
add(from("0.1"), from("0.2")); // "0.3" — not 0.30000000000000004
const share = divide(from("10"), from(3));
share; // "3.333333333333333333333333333333333" (34 significant digits)
toFixed(share, 2); // "3.33"
// Mixed input types are normalized for you:
add("1.5", 2n); // "3.5"There is no class and no new. A Decimal is a branded, canonical string — you create one
with from and operate on it with free functions. Every function accepts
Numeric = string | number | bigint | Decimal and normalizes internally.
npm install @ignromanov/decimal128
# or
pnpm add @ignromanov/decimal128
# or
yarn add @ignromanov/decimal128What it touches: nothing. Zero runtime dependencies. Pure functions over strings — no import-time
side effects, no filesystem, no network, no global config. It ships as dual ESM + CJS with
sideEffects: false, so bundlers tree-shake it down to just the operations you import. To remove it,
uninstall the package — there's no other footprint.
import { from, add, divide, round, toFixed } from "@ignromanov/decimal128";
const a = from("10");
const b = from(3);
add(a, from("0.5")); // "10.5"
divide(a, b); // "3.333333333333333333333333333333333" (34 significant digits)
divide(a, b, { maximumFractionDigits: 2 }); // "3.33"
round(from("2.5"), { maximumFractionDigits: 0 }); // "2" (default mode is half-even)
toFixed(divide(a, b), 4); // "3.3333"Because each export is a standalone function with no shared module-level state, importing a single
op (import { add } from "@ignromanov/decimal128") pulls in only that op's dependency graph — see
Tree-shaking.
Warning
A Decimal is a plain string at runtime, so native JS operators compile and run — they just
produce wrong answers. Read this section before you write a + b.
The upside of that representation: finite values get free ===, Map/object-key, and
JSON.stringify support. The downside is everything below — always go through the library
functions:
const a = from(1);
const b = from(2);
a + b; // "12" — string concatenation, NOT 3. Use add(a, b) → "3"
"9" < "10"; // false — lexicographic compare. Use lt(from(9), from(10)) → true
from(0) === from("-0"); // false — distinct strings. Use equals(from(0), from("-0")) → truea + b,a - b,a * b— never use arithmetic operators onDecimalvalues.+/-/*either concatenate or silently coerce throughNumber, both wrong. Useadd/subtract/multiply/divide.a < b,a > b,.sort()— comparisons on the raw string are lexicographic, not numeric ("9" < "10"isfalse). Uselt/lte/gt/gte/compare.a === b— canonical form makes equal finite, same-sign values equal as strings, but"0" !== "-0"even though they're numerically equal, and there's no reliable way to express "NaN-aware" equality with===. Useequals()for value equality; reserve===as a fast path only when you already know both sides are finite and non-zero.
API — every export is a standalone, tree-shakeable named export
| Export | Description |
|---|---|
Decimal, Numeric |
Decimal — branded canonical string. Numeric — permissive input union (string | number | bigint | Decimal). |
isDecimal(v) |
Type guard for Decimal. |
from(v) |
Parse Numeric → Decimal; throws DecimalError on invalid input. |
tryFrom(v) |
Parse Numeric → Result<Decimal>; never throws. |
add(a, b, options?) |
Addition. |
subtract(a, b, options?) / sub |
Subtraction. |
multiply(a, b, options?) / mul |
Multiplication. |
divide(a, b, options?) / div |
Division; ÷0 yields Infinity/-Infinity/NaN, never throws. |
remainder(a, b, options?) / mod |
Truncated remainder (sign follows the dividend); NaN when the integer quotient exceeds 34 digits (GDA "Division impossible" — see Intentional divergences). |
abs(a) |
Absolute value. |
negate(a) / neg |
Sign flip. |
pow(base, exponent, options?) |
Integer exponentiation — an extension beyond the TC39 proposal (see Semantics). |
compare(a, b) / cmp |
Total order, returns -1 | 0 | 1; NaN sorts last and equals itself (see Semantics). |
equals(a, b) / eq |
Value equality; NaN ≠ NaN, -0 == 0. |
lessThan(a, b) / lt |
IEEE-ordered <; any NaN operand → false. |
lessThanOrEqual(a, b) / lte |
IEEE-ordered <=. |
greaterThan(a, b) / gt |
IEEE-ordered >. |
greaterThanOrEqual(a, b) / gte |
IEEE-ordered >=. |
min(...values) |
Minimum of one or more values; throws DecimalError on zero arguments. |
max(...values) |
Maximum of one or more values; throws DecimalError on zero arguments. |
round(value, options?) |
Round to options.maximumFractionDigits under options.roundingMode. |
RoundingMode, RoundingOptions |
"ceil" | "floor" | "trunc" | "halfExpand" | "halfEven" (default halfEven); { maximumFractionDigits?, roundingMode? }. |
toString(value) |
Canonical string; scientific notation only outside [1e-6, 1e34). |
toFixed(value, digits) |
Fixed-point string with exactly digits fractional digits. |
toPrecision(value, precision) |
String with exactly precision significant digits. |
toExponential(value, fractionDigits?) |
Scientific-notation string. |
toNumber(value) |
Escape hatch to a JS number (nearest binary64) — for charts, Intl, third-party APIs; may lose precision. |
isFinite(v), isNaN(v), isNegative(v), isZero(v) |
Predicates over Numeric. |
DecimalError |
Thrown by from/pow/invalid options. Has .code: DecimalErrorCode. |
DecimalErrorCode, Result |
"INVALID_INPUT" | "INVALID_OPTION" | "INVALID_EXPONENT"; { ok: true, value } | { ok: false, error }. |
The four to* string formatters and toNumber return a string/number, not a Decimal.
Semantics — canonical form, precision, rounding, special values
- Canonical form: no trailing fractional zeros (
from("1.20")→"1.2"), no bare., no leading+; scientific notation only outside[1e-6, 1e34);-0is a distinct canonical value ("-0"), preserved because IEEE 754 requires sign propagation across composed operations (e.g.divide(1, negate(0))must be-Infinity). - Precision: up to 34 significant digits, quantum exponent range
[-6176, 6111]— the exact IEEE 754-2019 Decimal128 model. Results below the representable range underflow to±0; results above it overflow to±Infinity(except undertrunc/floor/ceil, where IEEE mandates returning the max-normal value instead of infinity). - Rounding modes:
ceil | floor | trunc | halfExpand | halfEven. Default ishalfEven("banker's rounding" — ties round to the nearest even digit), matching IEEE 754 and the TC39 proposal's default. remainderis truncated (fmod-style, sign follows the dividend), differing from IEEE 754's ownremainderoperation (which rounds half-to-even) and matching the TC39 proposal. It follows IBM General Decimal Arithmetic on the precision limit: when the integer quotienttrunc(x/y)would exceed 34 digits, the operation returnsNaN("Division impossible") rather than a value. See Intentional divergences.powis a documented extension beyond the TC39 proposal, which defines nopow. It accepts a non-negative integer exponent and rounds once per multiplication step.comparedefines a total order for sortability:NaNcompares equal to itself and greater than every other value.equals/lt/lte/gt/gteinstead follow strict IEEE semantics (NaNis never equal to, less than, or greater than anything, including itself).- Special values: a single quiet
NaN,Infinity,-Infinity,0,-0— all round-trip throughfrom/toStringas the literal strings"NaN","Infinity","-Infinity","0","-0".
Intentional divergences — where output differs from the reference oracle, and why
Every point where this library's output differs from a reference oracle — proposal-decimal
(the TC39 champion polyfill the differential suite tests against) or the IBM decTest vectors
(the known-answer suite in tests/dectest/) — is either a bug or a deliberate, documented
decision. These are the deliberate ones:
| Divergence | This library | Reference / oracle | Why |
|---|---|---|---|
remainder on a large quotient |
Returns NaN ("Division impossible") when the integer quotient trunc(x/y) exceeds 34 digits; otherwise the exact truncated modulo. |
The polyfill returns a rounded (sometimes silently corrupted) finite — it does not implement the GDA precision check. | Standard conformance: matches IBM General Decimal Arithmetic, which cannot form a >34-digit quotient in Decimal128 and so signals an invalid operation. |
pow |
Provided: integer exponent, rounds once per multiply step; negative exponent throws INVALID_EXPONENT. |
TC39 Decimal defines no pow. |
A common need, offered as a clearly namespaced extension rather than left out. |
compare (total order) vs equals (IEEE) |
compare is a total order — NaN sorts last and equals itself — so values are sortable. equals/lt/lte/gt/gte keep strict IEEE (NaN ≠ NaN). |
IEEE defines only the (partial) predicate semantics. | Sorting needs a total order; IEEE predicates need IEEE semantics. Both are provided under distinct names, never conflated. |
toString notation threshold |
Scientific notation only outside [1e-6, 1e34). |
The polyfill mimics legacy JS Number.toString thresholds (exponent ≥ 21 or ≤ -7). |
A decimal-appropriate threshold. This is a notation choice, not a value difference — the differential suite re-threads the polyfill's output through our own toString so it compares values, not notation. |
| Quantum (cohorts) | No quantum. 2, 2.0 and 2.00 are one value: from("1.50") === "1.5", add("1.0","1.0") === "2". |
IEEE 754-2019 / IBM GDA give each cohort member a distinct quantum and specify the result's quantum (1.0 + 1.0 → 2.0). The proposal-decimal polyfill normalizes exactly as we do. |
The TC39 Decimal type this library implements has no quantum. Consequence: the decTest suite compares by value, and quantize/canonical/the decimal128 encodings are out of scope — they mean nothing for a type without quanta. |
Tree-shaking — how one op stays ~3.8 KB
Because every export is a free function with no shared class or prototype, importing one
operation only pulls in its own dependency graph. A CI size guard
(scripts/size-check.mjs) bundles a fixture that imports a single op (add) with esbuild and
asserts it stays under a fixed threshold. The current measured single-op bundle is 3837 bytes
minified (~3.75 KB), against a 4608-byte ceiling — well below what a class-based decimal
library pulls in for the same op. Reproduce it with pnpm size-check.
This is a portfolio-grade, spec-aligned project, not a battle-tested production dependency with years of field use. A few things worth knowing before you adopt it:
- TC39 alignment is a design anchor, not a standardization bet. The TC39
Decimalproposal is Stage 1 and has seen no advancement since a 2024 Stage-2 decline. Aligning this library's API and semantics to it buys design discipline and a plausible future upgrade path to a nativeDecimal— it is not a claim that the proposal will ship. - Out-of-range operands resolve at construction, not at the operation. Because a value is a
canonical Decimal128 string, a literal outside the representable range is rounded (or overflowed)
when it is parsed — before any operation runs. So
multiply("1e6145", "1e-100")returnsInfinity: the1e6145operand overflows on ingest even though the mathematical product1e6045is perfectly representable. This matchesproposal-decimal(the reference polyfill behaves identically) but differs from an arbitrary-precision decimal such as Python'sdecimalor decNumber, which keep the operand exact and round only the in-range result. Operand parsing always useshalfEven; a per-operationroundingModeapplies to the result, not to ingest. - Correctness is tested from three independent angles. The test suite runs 2000+ seeded
input pairs against
proposal-decimal, the TC39 champion's own reference polyfill, and asserts parity across arithmetic, rounding, comparison, and formatting. That comparison surfaced concrete points where the polyfill diverges from strict IEEE 754 behavior — no subnormal support, differences in signed-zero and division-by-zero handling, and remainder precision on large operands — which this library handles per-spec instead. That's offered as measured evidence of what the test suite actually caught, not a claim of superiority to the proposal itself. Alongside the differential suite, a known-answer suite runs 2912 vectors from IBM'sdq*decTest files through the public API and compares by value. And an oracle-free set of metamorphic property tests runs over a boundary-dense generator that reaches subnormals (1e-6176), max-normal, and exact ties — the range the differential suite's PRNG structurally cannot reach.