UNPKG

@juici/math

Version:

A mathematics utility library

664 lines (566 loc) 16 kB
import { cmp, divRem } from "./math"; import { parseDecimal } from "./parse"; import { customInspectSymbol, setHasInstance } from "./util"; import type { InspectOptionsStylized } from "node:util"; export type DecimalValue = string | number | bigint | BigDecimalLike; export interface BigDecimalLike { readonly digits: bigint; readonly scale: number; } /** * A big decimal type. */ export class BigDecimal { /** * The digits in this BigDecimal. */ readonly digits: bigint; /** * The scale by which the digits in this BigDecimal are shifted. */ readonly scale: number; /** * Creates a BigDecimal with the given digits and scale. */ constructor(digits: bigint, scale: number); /** * Creates a BigDecimal from the given value. */ constructor(n: DecimalValue); constructor(n: DecimalValue | bigint, scale?: number) { if (scale !== undefined) { if (!Number.isInteger(scale)) { throw new TypeError("Argument 'scale' must be an integer"); } if (typeof n !== "bigint") { throw new TypeError("Argument 'digits' must be a bigint"); } this.digits = n; this.scale = scale; } else { [this.digits, this.scale] = components(n); } [this.digits, this.scale] = normalize(this.digits, this.scale); } /** * The number of decimal places in this BigDecimal. */ get dp(): number { return this.scale < 0 ? 0 : this.scale; } /** * The sign of this BigDecimal. */ get sign(): -1 | 0 | 1 { return this.digits === 0n ? 0 : this.digits < 0n ? -1 : 1; } /** * Checks if this BigDecimal is an integer. */ isInt(): boolean { return this.scale <= 0; } /** * Checks if this BigDecimal is negative. */ isNeg(): boolean { return this.digits < 0n; } /** * Checks if this BigDecimal is positive. */ isPos(): boolean { return this.digits > 0n; } /** * Checks if this BigDecimal is 0. */ isZero(): boolean { return this.digits === 0n; } /** * Checks if this BigDecimal is 1. */ isOne(): boolean { return this.scale === 0 && this.digits === 1n; } /** * Checks for equality of this BigDecimal with the given value. */ eq(other: DecimalValue): boolean { const [digits, scale] = normalize(...components(other)); return this.digits === digits && this.scale === scale; } /** * Returns the the ordering of this BigDecimal and the given value. */ cmp(other: DecimalValue): -1 | 0 | 1 { const n = new BigDecimal(other); const s1 = this.sign; const s2 = n.sign; // Either zero? if (s1 === 0 || s2 === 0) { return s1 !== 0 ? s1 : s2 !== 0 ? (-s2 as -1 | 1) : 0; } // Compare signs. if (s1 !== s2) { return s1; } let d1 = this.digits; let d2 = n.digits; // Adjust the digits to the same scale. if (this.scale < n.scale) { d1 *= 10n ** BigInt(n.scale - this.scale); } else if (this.scale > n.scale) { d2 *= 10n ** BigInt(this.scale - n.scale); } return cmp(d1, d2); } /** * Checks if this BigDecimal is less than the given value. */ lt(other: DecimalValue): boolean { return this.cmp(other) < 0; } /** * Checks if this BigDecimal is less than or equal to the given value. */ le(other: DecimalValue): boolean { return this.cmp(other) <= 0; } /** * Checks if this BigDecimal is greater than the given value. */ gt(other: DecimalValue): boolean { return this.cmp(other) > 0; } /** * Checks if this BigDecimal is greater than or equal to the given value. */ ge(other: DecimalValue): boolean { return this.cmp(other) >= 0; } /** * Returns the absolute value of this BigDecimal. */ abs(): BigDecimal { return this.isNeg() ? this.neg() : this; } /** * Returns the negation of this BigDecimal. */ neg(): BigDecimal { return new BigDecimal(-this.digits, this.scale); } /** * Returns the addition of this BigDecimal with the given value. */ add(other: DecimalValue): BigDecimal { const { digits: ld, scale: ls } = this; const [rd, rs] = components(other); const [l, r, scale] = withScale(ld, ls, rd, rs); return new BigDecimal(l + r, scale); } /** * Returns the subtraction of this BigDecimal by the given value. */ sub(other: DecimalValue): BigDecimal { const { digits: ld, scale: ls } = this; const [rd, rs] = components(other); const [l, r, scale] = withScale(ld, ls, rd, rs); return new BigDecimal(l - r, scale); } /** * Returns the multiplication of this BigDecimal with the given value. */ mul(other: DecimalValue): BigDecimal { let [digits, scale] = components(other); digits *= this.digits; scale += this.scale; return new BigDecimal(digits, scale); } /** * Returns the division of this BigDecimal by the given value. * * The result is rounded if necessary. * * @param dp The maximum number of decimal places precision, default `20`. * @throws {RangeError} If `other` is `0`. */ div(other: DecimalValue, dp: number = 20): BigDecimal { dp = validateDP(dp, "dp"); const n = new BigDecimal(other); if (n.isZero()) { throw new RangeError("Division by zero"); } if (this.isZero() || n.isOne()) { return this; } const scale = this.scale - n.scale; if (this.digits === n.digits) { return new BigDecimal(1n, scale); } return implDiv(this.digits, n.digits, scale, dp); } /** * Returns the remainder of this BigDecimal divided by the given value. * * @throws {RangeError} If `other` is `0`. */ rem(other: DecimalValue): BigDecimal { const [rd, rs] = components(other); if (rd === 0n) { throw new RangeError("Division by zero"); } const { digits: ld, scale: ls } = this; const [l, r, scale] = withScale(ld, ls, rd, rs); return new BigDecimal(l % r, scale); } /** * Returns the division of this BigDecimal by `2`. * * This function is more efficient than `.div(2)`. */ half(): BigDecimal { if (this.isZero()) { return this; } else if (this.digits % 2n === 0n) { return new BigDecimal(this.digits / 2n, this.scale); } else { return new BigDecimal(this.digits * 5n, this.scale + 1); } } /** * Returns the value of this BigDecimal rounded to the given number of decimal places. */ toDP(dp: number): BigDecimal { dp = validateDP(dp, "dp"); if (dp >= this.scale) { return this; } const factor = 10n ** BigInt(this.scale - dp); const [q, r] = divRem(this.digits, factor); return new BigDecimal(q + roundingTerm(r), dp); } /** * Returns the value of this BigDecimal converted to a primitive number. */ toNumber(): number { return Number(this.toString()); } /** * Returns the value of this BigDecimal rounded to a primitive bigint. */ toBigInt(): bigint { if (this.scale === 0) { return this.digits; } else if (this.scale > 0) { const factor = 10n ** BigInt(this.scale); const [q, r] = divRem(this.digits, factor); return q + roundingTerm(r); } else { const factor = 10n ** BigInt(-this.scale); return this.digits * factor; } } /** * Returns a string representing the value of this BigDecimal. */ toString(): string { const neg = this.digits < 0n; const digits = neg ? (-this.digits).toString() : this.digits.toString(); const len = digits.length; let before: string; let after: string; if (this.scale >= len) { before = "0"; after = "0".repeat(this.scale - len) + digits; } else { const pos = len - this.scale; if (pos > len) { before = digits + "0".repeat(pos - len); after = ""; } else { before = digits.slice(0, pos); after = digits.slice(pos); } } let s = before; if (after.length > 0) { s += `.${after}`; } return neg ? `-${s}` : s; } /** * Returns this BigDecimal formatted using fixed-point notation. * * The result is rounded if necessary, and the fractional component is padded * with zeros if necessary so that it has the specified length. * * @param dp The number of decimal places, default `0`. * @throws {RangeError} If `dp` is less than `0`. */ toFixed(dp: number = 0): string { dp = validateDP(dp, "dp"); let digits = this.digits; let scale = this.scale; if (dp < scale) { const factor = 10n ** BigInt(scale - dp); const [q, r] = divRem(digits, factor); digits = q + roundingTerm(r); scale = dp; } const neg = digits < 0n; let repr = neg ? (-digits).toString() : digits.toString(); const len = repr.length; let before: string; let after: string; if (scale >= len) { before = "0"; after = "0".repeat(scale - len) + repr; } else { const pos = len - scale; if (pos > len) { before = repr + "0".repeat(pos - len); after = ""; } else { before = repr.slice(0, pos); after = repr.slice(pos); } } if (after.length < dp) { after += "0".repeat(dp - after.length); } repr = before; if (after.length > 0) { repr += `.${after}`; } return neg ? `-${repr}` : repr; } /** * Returns this BigDecimal formatted using exponential notation, with one * digit before the decimal point. * * The result is rounded if necessary, and the fractional component is padded * with zeros if necessary so that it has the specified length. * * @param dp The number of decimal places, default to the number of decimal * places required to represent the value uniquely. * @throws {RangeError} If `dp` is less than `0`. */ toExponential(dp?: number): string { const neg = this.digits < 0n; let digits = neg ? -this.digits : this.digits; let scale = this.scale; if (dp !== undefined) { dp = validateDP(dp, "dp"); if (scale < 0) { const factor = 10n ** BigInt(-scale); digits *= factor; scale = 0; } const extra = digits.toString().length - 1 - dp; if (extra > 0) { const factor = 10n ** BigInt(extra); const [q, r] = divRem(digits, factor); digits = q + roundingTerm(r); scale -= extra; } } let exp = -scale; let before = digits.toString(); let after = ""; const len = before.length; if (len > 1) { after = before.slice(1); before = before.slice(0, 1); exp += len - 1; } if (dp !== undefined && after.length < dp) { after += "0".repeat(dp - after.length); } let repr = before; if (after.length > 0) { repr += `.${after}`; } repr += `e${exp >= 0 ? "+" : ""}${exp}`; return neg ? `-${repr}` : repr; } /** * Returns a string representing the value of this BigDecimal. */ toJSON(): string { return this.toString(); } /** * Returns a string representing the value of this BigDecimal. */ valueOf(): string { return this.toString(); } /** * Getter for the string tag used in the `Object.prototype.toString` method. */ get [Symbol.toStringTag](): "BigDecimal" { return "BigDecimal"; } /** * Converts a BigDecimal into a string. */ [Symbol.toPrimitive](hint: "string"): string; /** * Converts a BigDecimal into a number. */ [Symbol.toPrimitive](hint: "number" | "default"): number; /** * Converts a BigDecimal into a string or number. * * @param hint The string "string", "number", or "default" to specify what primitive to return. * * @throws {TypeError} If `hint` was given something other than "string", "number", or "default". * @returns A number if `hint` was "number", a string if 'hint' was "string" or "default". */ [Symbol.toPrimitive](hint: string): string | number { switch (hint) { case "number": return this.toNumber(); case "string": case "default": return this.toString(); default: throw new TypeError(`Invalid hint: ${hint}`); } } /** * Custom inspection function for Node.js. * * @internal */ [customInspectSymbol](_depth: number, options: InspectOptionsStylized): string { return options.stylize(this.toString(), "number"); } } setHasInstance(BigDecimal); function isBigDecimalLike(n: unknown): n is BigDecimalLike { return ( typeof n === "object" && n !== null && typeof (n as { digits?: unknown }).digits === "bigint" && typeof (n as { scale?: unknown }).scale === "number" ); } function implDiv(numer: bigint, denom: bigint, scale: number, dp: number): BigDecimal { if (numer === 0n) { return new BigDecimal(0n); } // Shuffle signs around to have position `numer` and `denom`. let neg = false; if (numer < 0n) { numer = -numer; if (denom < 0n) { denom = -denom; } else { neg = true; } } else if (denom < 0n) { denom = -denom; neg = true; } // Shift digits of `numer` until larger than `denom`, adjusting `scale` appropriately. while (numer < denom) { numer *= 10n; scale++; } // First division. let [quotient, remainder] = divRem(numer, denom); if (scale > dp) { while (scale > dp) { [quotient, remainder] = divRem(quotient, 10n); scale--; } if (remainder !== 0n) { // Round the final number with the remainder. quotient += roundingTerm(remainder); } } else { // Shift remainder by 1 place, before loop to find digits of decimal places. remainder *= 10n; while (remainder !== 0n && scale < dp) { const [q, r] = divRem(remainder, denom); quotient = quotient * 10n + q; remainder = r * 10n; scale++; } if (remainder !== 0n) { // Round the final number with the remainder. quotient += roundingTerm(remainder / denom); } } return new BigDecimal(neg ? -quotient : quotient, scale); } function validateDP(dp: number, arg: string): number { if (dp < 0) { throw new RangeError(`Argument '${arg}' must be >= 0`); } return Math.trunc(dp); } function roundingTerm(n: bigint): -1n | 0n | 1n { // Compare by char code to avoid parsing digit. // 53 is the UTF-16 char code of "5". if (n < 0n) { const digit = (-n).toString().charCodeAt(0); return digit >= 53 ? -1n : 0n; } else { const digit = n.toString().charCodeAt(0); return digit >= 53 ? 1n : 0n; } } function components(n: DecimalValue): [bigint, number] { if (isBigDecimalLike(n)) { return [n.digits, n.scale]; } switch (typeof n) { case "bigint": return [n, 0]; case "number": if (!Number.isFinite(n)) { throw new RangeError(`BigDecimal must be finite: ${n}`); } if (Number.isSafeInteger(n)) { return [BigInt(n), 0]; } return parseDecimal(n.toExponential()); case "string": return parseDecimal(n); default: break; } // Whilst our API says we don't accept any other types here, we'll make a // best attempt to try to parse a big decimal from the string representation. const repr = String(n); try { return parseDecimal(repr); } catch (err) { throw new TypeError(`Cannot convert '${repr}' to a BigDecimal`, { cause: err }); } } function normalize(digits: bigint, scale: number): [bigint, number] { if (digits === 0n) { return [0n, 0]; } while (digits % 10n === 0n) { digits /= 10n; scale--; } return [digits, scale]; } function withScale(ld: bigint, ls: number, rd: bigint, rs: number): [bigint, bigint, number] { let scale = ls; if (ls < rs) { ld *= 10n ** BigInt(rs - ls); scale = rs; } else if (ls > rs) { rd *= 10n ** BigInt(ls - rs); } return [ld, rd, scale]; }