From 0f8f2ea5bd6ad5ac9bd77132253d79d9a2eec400 Mon Sep 17 00:00:00 2001 From: SibiSudhanElangovan Date: Wed, 21 Oct 2026 13:01:41 +0530 Subject: [PATCH] feat(data): add fixed-precision decimal arithmetic --- src/lib/data/fixed-decimal.ts | 237 ++++++++++++++++++++++++++++++++++ tests/fixed-decimal.test.ts | 49 +++++++ 2 files changed, 286 insertions(+) create mode 100644 src/lib/data/fixed-decimal.ts create mode 100644 tests/fixed-decimal.test.ts diff --git a/src/lib/data/fixed-decimal.ts b/src/lib/data/fixed-decimal.ts new file mode 100644 index 0000000..23e291e --- /dev/null +++ b/src/lib/data/fixed-decimal.ts @@ -0,0 +1,237 @@ +export type RoundingMode = "half-even" | "half-up" | "truncate"; + +export interface DecimalFormatOptions { + precision?: number; + roundingMode?: RoundingMode; + commaSeparated?: boolean; + currencyPrefix?: string; +} + +type DecimalParts = { coefficient: bigint; scale: number }; + +const MAX_PRECISION = 100; +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +function validatePrecision(p: number): void { + if (!Number.isInteger(p) || p < 0 || p > MAX_PRECISION) { + throw new RangeError(`Precision must be an integer from 0 to ${MAX_PRECISION}`); + } +} + +function validateMode(mode: RoundingMode): void { + if (mode !== "half-even" && mode !== "half-up" && mode !== "truncate") { + throw new RangeError(`Unsupported rounding mode: ${mode}`); + } +} + +function pow10(n: number): bigint { + return BigInt(10) ** BigInt(n); +} + +function parseDecimal(value: number | string): DecimalParts { + if (typeof value === "number" && !Number.isFinite(value)) { + throw new TypeError("Decimal value must be finite"); + } + + const text = String(value).trim(); + const match = + /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/.exec(text); + + if (!match) throw new TypeError(`Invalid decimal value: ${text}`); + + const sign = match[1] === "-" ? -BigInt(1) : BigInt(1); + const integer = match[2] ?? "0"; + const fraction = match[3] ?? match[4] ?? ""; + const exponent = Number(match[5] ?? "0"); + + if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > MAX_PRECISION) { + throw new RangeError("Decimal exponent is outside the supported range"); + } + + let coefficient = BigInt((integer + fraction).replace(/^0+(?=\d)/, "")); + let scale = fraction.length - exponent; + + if (scale < 0) { + coefficient *= pow10(-scale); + scale = 0; + } + + if (scale > MAX_PRECISION * 2) { + throw new RangeError("Decimal scale is outside the supported range"); + } + + return { coefficient: coefficient * sign, scale }; +} + +function divideRounded( + numerator: bigint, + denominator: bigint, + mode: RoundingMode, +): bigint { + if (denominator === BigInt(0)) throw new RangeError("Division by zero"); + validateMode(mode); + + let n = numerator; + let d = denominator; + + if (d < BigInt(0)) { + n = -n; + d = -d; + } + + const quotient = n / d; + const remainder = n % d; + + if (remainder === BigInt(0) || mode === "truncate") return quotient; + + const twice = (remainder < BigInt(0) ? -remainder : remainder) * BigInt(2); + if (twice < d) return quotient; + + const direction = n < BigInt(0) ? -BigInt(1) : BigInt(1); + if (twice > d || mode === "half-up") return quotient + direction; + + return quotient % BigInt(2) === BigInt(0) ? quotient : quotient + direction; +} + +function rescale( + coefficient: bigint, + sourceScale: number, + precision: number, + mode: RoundingMode, +): bigint { + if (sourceScale <= precision) { + return coefficient * pow10(precision - sourceScale); + } + return divideRounded( + coefficient, + pow10(sourceScale - precision), + mode, + ); +} + +function toResult(scaled: bigint, precision: number): number { + if (scaled > MAX_SAFE || scaled < -MAX_SAFE) { + throw new RangeError("Result exceeds the safe integer range at this precision"); + } + + const result = Number(scaled) / 10 ** precision; + if (!Number.isFinite(result)) throw new RangeError("Result is not finite"); + return result; +} + +function addOrSubtract( + a: number | string, + b: number | string, + precision: number, + subtract: boolean, +): number { + validatePrecision(precision); + + const left = parseDecimal(a); + const right = parseDecimal(b); + const scale = Math.max(left.scale, right.scale); + const x = left.coefficient * pow10(scale - left.scale); + const y = right.coefficient * pow10(scale - right.scale); + const total = x + (subtract ? -y : y); + + return toResult(rescale(total, scale, precision, "half-even"), precision); +} + +export function addFixed( + a: number | string, + b: number | string, + precision = 2, +): number { + return addOrSubtract(a, b, precision, false); +} + +export function subFixed( + a: number | string, + b: number | string, + precision = 2, +): number { + return addOrSubtract(a, b, precision, true); +} + +export function mulFixed( + a: number | string, + b: number | string, + precision = 2, + mode: RoundingMode = "half-even", +): number { + validatePrecision(precision); + validateMode(mode); + const x = parseDecimal(a); + const y = parseDecimal(b); + return toResult( + rescale(x.coefficient * y.coefficient, x.scale + y.scale, precision, mode), + precision, + ); +} + +export function divFixed( + a: number | string, + b: number | string, + precision = 2, + mode: RoundingMode = "half-even", +): number { + validatePrecision(precision); + validateMode(mode); + + const x = parseDecimal(a); + const y = parseDecimal(b); + if (y.coefficient === BigInt(0)) throw new RangeError("Division by zero"); + + const exponent = precision + y.scale - x.scale; + const numerator = exponent >= 0 + ? x.coefficient * pow10(exponent) + : x.coefficient; + const denominator = exponent >= 0 + ? y.coefficient + : y.coefficient * pow10(-exponent); + + return toResult(divideRounded(numerator, denominator, mode), precision); +} + +export function roundFixed( + value: number | string, + precision = 2, + mode: RoundingMode = "half-even", +): number { + validatePrecision(precision); + validateMode(mode); + const parsed = parseDecimal(value); + return toResult(rescale(parsed.coefficient, parsed.scale, precision, mode), precision); +} + +export function formatFixed( + value: number | string, + options: DecimalFormatOptions = {}, +): string { + const { + precision = 2, + roundingMode = "half-even", + commaSeparated = true, + currencyPrefix = "", + } = options; + + validatePrecision(precision); + validateMode(roundingMode); + + const parsed = parseDecimal(value); + const scaled = rescale(parsed.coefficient, parsed.scale, precision, roundingMode); + const negative = scaled < BigInt(0); + const digits = (negative ? -scaled : scaled).toString().padStart(precision + 1, "0"); + + let integerPart = precision === 0 ? digits : digits.slice(0, -precision); + const fractionPart = precision === 0 ? "" : digits.slice(-precision); + + if (commaSeparated) { + integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + + return `${negative ? "-" : ""}${currencyPrefix}${integerPart}${ + precision === 0 ? "" : `.${fractionPart}` + }`; +} + diff --git a/tests/fixed-decimal.test.ts b/tests/fixed-decimal.test.ts new file mode 100644 index 0000000..d4e88a4 --- /dev/null +++ b/tests/fixed-decimal.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { + addFixed, + subFixed, + mulFixed, + divFixed, + roundFixed, + formatFixed, +} from "../src/lib/data/fixed-decimal"; + +assert.equal(addFixed(0.1, 0.2, 2), 0.3); +assert.equal(addFixed("12.345", "0.005", 2), 12.35); +assert.equal(subFixed("1.0", "0.9", 2), 0.1); +assert.equal(subFixed("-1.25", "-2.50", 2), 1.25); + +assert.equal(mulFixed("19.99", 3, 2), 59.97); +assert.equal(mulFixed("1.25", "2.5", 3), 3.125); +assert.equal(divFixed(1, 3, 2), 0.33); +assert.equal(divFixed(10, 4, 3), 2.5); +assert.throws(() => divFixed(1, 0), RangeError); + +assert.equal(roundFixed("2.5", 0, "half-even"), 2); +assert.equal(roundFixed("3.5", 0, "half-even"), 4); +assert.equal(roundFixed("-2.5", 0, "half-even"), -2); +assert.equal(roundFixed("2.5", 0, "half-up"), 3); +assert.equal(roundFixed("-2.5", 0, "half-up"), -3); +assert.equal(roundFixed("2.99", 0, "truncate"), 2); +assert.equal(roundFixed("-2.99", 0, "truncate"), -2); + +assert.equal(formatFixed("1250.5", { currencyPrefix: "$" }), "$1,250.50"); +assert.equal(formatFixed("12", { precision: 4 }), "12.0000"); +assert.equal(formatFixed("-1234.567"), "-1,234.57"); +assert.equal( + formatFixed("1234.5", { commaSeparated: false }), + "1234.50", +); +assert.equal(formatFixed("42", { precision: 0 }), "42"); + +assert.equal(addFixed("1e-2", "2e-2", 2), 0.03); +assert.equal(mulFixed("-0.1", "0.2", 3), -0.02); +assert.equal(roundFixed("0.000123", 4), 0.0001); + +assert.throws(() => addFixed("abc", 1), TypeError); +assert.throws(() => roundFixed(Number.NaN), TypeError); +assert.throws(() => roundFixed(1, -1), RangeError); +assert.throws(() => roundFixed(1, 101), RangeError); +assert.throws(() => roundFixed(1, 2, "invalid" as never), RangeError); + +console.log("fixed-decimal tests passed");