@absoluteui/helpers/decimal
Exact fixed-point decimal arithmetic for money, with rounding modes and allocation.
import { ... } from '@absoluteui/helpers/decimal';How to use
Section titled “How to use”Exact decimal arithmetic for money, rates, and quantities, where 0.1 + 0.2 must be 0.3. Values are immutable Decimal objects backed by bigint, serialise to JSON as strings, and refuse silent conversion to floating point. The Helpers and services guide covers the shared conventions.
How do I total invoice lines and add tax exactly?
Section titled “How do I total invoice lines and add tax exactly?”Multiply price by quantity, sum with sumDecimals, and round only once, at the end, with an explicit rounding mode.
import { decimal, sumDecimals } from '@absoluteui/helpers/decimal';
const lines = [ { price: '19.99', qty: 3 }, { price: '4.35', qty: 7 }, { price: '0.10', qty: 2 },];const net = sumDecimals(lines.map((l) => decimal(l.price).multiply(l.qty)));net.toString(); // => '90.62'const vat = net.percent('7.5', { scale: 2, rounding: 'half-even' });vat.toString(); // => '6.80'net.add(vat).toFixed(2); // => '97.42'0.1 + 0.2 === 0.3; // => falsedecimal('0.1').add('0.2').equals('0.3'); // => trueHow do I split an amount without losing a cent?
Section titled “How do I split an amount without losing a cent?”split divides into equal parts and allocate by ratios; the leftover minor units go to the first parts, so the parts always add up to the total.
import { decimal, sumDecimals } from '@absoluteui/helpers/decimal';
const bonusPool = decimal('1000.00');const shares = bonusPool.split(3);shares.map(String); // => ['333.34', '333.33', '333.33']sumDecimals(shares).equals(bonusPool); // => trueconst byFte = bonusPool.allocate([1, 0.5, 0.5]); // full-time, two part-timebyFte.map(String); // => ['500.00', '250.00', '250.00']How do I read user input and store amounts in cents?
Section titled “How do I read user input and store amounts in cents?”parseDecimal returns undefined instead of throwing, and accepts a locale for grouped input such as 1.234,50. Store integer minor units with toMinorUnits and restore them with fromMinorUnits.
import { fromMinorUnits, parseDecimal } from '@absoluteui/helpers/decimal';
const salary = parseDecimal('4.250,75', { locale: 'de' });salary?.toString(); // => '4250.75'parseDecimal('12,5 EUR'); // => undefinedconst cents = salary?.toMinorUnits(2);cents; // => 425075nfromMinorUnits(425075n, 2).toString(); // => '4250.75'How do I display a decimal as currency?
Section titled “How do I display a decimal as currency?”formatDecimal formats the exact string through Intl.NumberFormat, so digits beyond float precision survive.
import { decimal, formatDecimal } from '@absoluteui/helpers/decimal';
formatDecimal(decimal('1234.5'), { currency: 'USD', locale: 'en-US' }); // => '$1,234.50'formatDecimal('9007199254740993.10', { locale: 'en-US' }); // => '9,007,199,254,740,993.10'JSON.stringify({ amount: decimal('12.30') }); // => '{"amount":"12.30"}'What goes wrong?
Section titled “What goes wrong?”Invalid strings, division by zero, and numeric coercion throw with stable codes. Coercion (+d, d * 2) is blocked on purpose: call toNumber() when you really want a float.
import { decimal } from '@absoluteui/helpers/decimal';
decimal('12.3x'); // => throws HLP_DECIMAL_PARSEdecimal('10').divide(0); // => throws HLP_DECIMAL_DIVIDE_BY_ZERONumber(decimal('1.5')); // => throws HLP_DECIMAL_COERCIONAPI reference
Section titled “API reference”Functions
Section titled “Functions”compareDecimals
Comparator for Array#sort.
function compareDecimals(a: DecimalInput, b: DecimalInput): -1 | 0 | 1;Example:
['2', '10', '1.5'].map((s) => decimal(s)).sort(compareDecimals)decimal
Creates a Decimal. Strings are parsed strictly ("-12.50"); exponents and grouping need options.
Throws HLP_DECIMAL_PARSE (ValidationError) or HLP_DECIMAL_TOO_LARGE (LimitError).
function decimal(value: DecimalInput, options?: DecimalOptions): Decimal;Example:
decimal('19.99').multiply(3).toString() // => '59.97'formatDecimal
Formats through Intl.NumberFormat using the exact decimal string (no float conversion).
Without currency, all fractional digits of the scale are shown by default.
Intl rounds with roundingMode (default half-expand = half-up).
function formatDecimal(value: DecimalInput, options?: FormatDecimalOptions): string;Example:
formatDecimal(decimal('1234567890123456789.25'), { locale: 'en-US' }) // => '1,234,567,890,123,456,789.25'fromMinorUnits
Decimal from integer minor units (cents) with digits fraction digits.
function fromMinorUnits(minor: bigint | number, digits?: number): Decimal;Example:
fromMinorUnits(1999n, 2).toString() // => '19.99'isDecimal
True for Decimal instances.
function isDecimal(value: unknown): value is Decimal;Example:
isDecimal(decimal(1)) // => truemaxDecimal
Largest value, or undefined for none.
function maxDecimal(...values: DecimalInput[]): Decimal | undefined;Example:
maxDecimal('2', '10')?.toString() // => '10'minDecimal
Smallest value, or undefined for none.
function minDecimal(...values: DecimalInput[]): Decimal | undefined;Example:
minDecimal('2', '1.5')?.toString() // => '1.5'parseDecimal
Non-throwing decimal() for untrusted text: undefined when invalid (limit errors still throw).
function parseDecimal(text: string, options?: DecimalOptions): Decimal | undefined;Example:
parseDecimal('12.3x') // => undefinedsumDecimals
Exact sum; empty input gives 0.
function sumDecimals(values: Iterable<DecimalInput>): Decimal;Example:
sumDecimals(['0.1', '0.2']).toString() // => '0.3'Classes
Section titled “Classes”Decimal
Immutable decimal: unscaled x 10^-scale. Create with decimal().
class Decimal { readonly unscaled: bigint; readonly scale: number; constructor(unscaled: bigint, scale?: number); add(other: DecimalInput): Decimal; subtract(other: DecimalInput): Decimal; multiply(other: DecimalInput, options?: DecimalRoundOptions): Decimal; divide(other: DecimalInput, options?: DecimalRoundOptions): Decimal; negate(): Decimal; abs(): Decimal; compare(other: DecimalInput): -1 | 0 | 1; equals(other: DecimalInput): boolean; gt(other: DecimalInput): boolean; gte(other: DecimalInput): boolean; lt(other: DecimalInput): boolean; lte(other: DecimalInput): boolean; isZero(): boolean; isNegative(): boolean; isPositive(): boolean; round(scale: number, mode?: RoundingMode): Decimal; normalize(): Decimal; toString(): string; toFixed(places: number, mode?: RoundingMode): string; toNumber(): number; toMinorUnits(digits?: number, mode?: RoundingMode): bigint; allocate(ratios: readonly DecimalInput[], options?: AllocateOptions): Decimal[]; split(n: number, options?: AllocateOptions): Decimal[]; percent(pct: DecimalInput, options?: DecimalRoundOptions): Decimal; percentageOf(total: DecimalInput, options?: DecimalRoundOptions): Decimal; toJSON(): string; [Symbol.toPrimitive](hint: string): string;}Example:
decimal('0.10').add('0.20').toString() // => '0.30'Example (add):
decimal('1.1').add('2.25').toString() // => '3.35'Example (subtract):
decimal('1').subtract('0.9').toString() // => '0.1'Example (multiply):
decimal('1.5').multiply('1.5').toString() // => '2.25'Example (divide):
decimal(1).divide(3, { scale: 4 }).toString() // => '0.3333'Example (negate):
decimal('2').negate().toString() // => '-2'Example (abs):
decimal('-2.5').abs().toString() // => '2.5'Example (compare):
decimal('1.0').compare('1') // => 0Example (equals):
decimal('1.50').equals('1.5') // => trueExample (gt):
decimal(2).gt(1) // => trueExample (gte):
decimal(2).gte(2) // => trueExample (lt):
decimal(1).lt(2) // => trueExample (lte):
decimal(2).lte(2) // => trueExample (isZero):
decimal('0.00').isZero() // => trueExample (isNegative):
decimal('-0.01').isNegative() // => trueExample (isPositive):
decimal('0.01').isPositive() // => trueExample (round):
decimal('2.345').round(2, 'half-even').toString() // => '2.34'Example (normalize):
decimal('1.500').normalize().toString() // => '1.5'Example (toString):
decimal('-0.5').toString() // => '-0.5'Example (toFixed):
decimal('1.005').toFixed(2) // => '1.01'Example (toNumber):
decimal('0.1').toNumber() // => 0.1Example (toMinorUnits):
decimal('12.345').toMinorUnits(2) // => 1235nExample (allocate):
decimal('100').allocate([1, 1, 1], { scale: 2 }).map(String) // => ['33.34', '33.33', '33.33']Example (split):
decimal('10.00').split(3).map(String) // => ['3.34', '3.33', '3.33']Example (percent):
decimal('200').percent('7.5').toString() // => '15.000'Example (percentageOf):
decimal(15).percentageOf(200, { scale: 1 }).toString() // => '7.5'Example (toJSON):
JSON.stringify({ a: decimal('1.10') }) // => '{"a":"1.10"}'Constants
Section titled “Constants”DEFAULT_DIVISION_SCALE
Fractional digits of a division result when no scale is given (at least the operands’ scales).
const DEFAULT_DIVISION_SCALE = 20;MAX_DECIMAL_DIGITS
Maximum number of digits in the unscaled value.
const MAX_DECIMAL_DIGITS = 1000;MAX_DECIMAL_SCALE
Maximum number of fractional digits.
const MAX_DECIMAL_SCALE = 1000;Interfaces
Section titled “Interfaces”AllocateOptions
interface AllocateOptions { readonly scale?: number;}DecimalOptions
interface DecimalOptions extends DecimalRoundOptions { readonly allowExponent?: boolean; readonly locale?: LocaleInput;}DecimalRoundOptions
interface DecimalRoundOptions { readonly scale?: number; readonly rounding?: RoundingMode;}FormatDecimalOptions
interface FormatDecimalOptions extends Omit<FormatNumberOptions, 'style' | 'currency'> { readonly currency?: string; readonly display?: 'symbol' | 'narrowSymbol' | 'code' | 'name';}DecimalInput
Anything accepted where a Decimal is expected. Numbers use their shortest round-trip form (0.1 is exactly 0.1).
type DecimalInput = Decimal | string | number | bigint;RoundingMode
Rounding modes. half-up rounds ties away from zero; up/down are away from/toward zero.
type RoundingMode = 'half-up' | 'half-even' | 'half-down' | 'up' | 'down' | 'ceiling' | 'floor';