Skip to content

@absoluteui/helpers/decimal

Exact fixed-point decimal arithmetic for money, with rounding modes and allocation.

import { ... } from '@absoluteui/helpers/decimal';

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; // => false
decimal('0.1').add('0.2').equals('0.3'); // => true

How 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); // => true
const byFte = bonusPool.allocate([1, 0.5, 0.5]); // full-time, two part-time
byFte.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'); // => undefined
const cents = salary?.toMinorUnits(2);
cents; // => 425075n
fromMinorUnits(425075n, 2).toString(); // => '4250.75'

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"}'

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_PARSE
decimal('10').divide(0); // => throws HLP_DECIMAL_DIVIDE_BY_ZERO
Number(decimal('1.5')); // => throws HLP_DECIMAL_COERCION

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)) // => true

maxDecimal

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') // => undefined

sumDecimals

Exact sum; empty input gives 0.

function sumDecimals(values: Iterable<DecimalInput>): Decimal;

Example:

sumDecimals(['0.1', '0.2']).toString() // => '0.3'

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') // => 0

Example (equals):

decimal('1.50').equals('1.5') // => true

Example (gt):

decimal(2).gt(1) // => true

Example (gte):

decimal(2).gte(2) // => true

Example (lt):

decimal(1).lt(2) // => true

Example (lte):

decimal(2).lte(2) // => true

Example (isZero):

decimal('0.00').isZero() // => true

Example (isNegative):

decimal('-0.01').isNegative() // => true

Example (isPositive):

decimal('0.01').isPositive() // => true

Example (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.1

Example (toMinorUnits):

decimal('12.345').toMinorUnits(2) // => 1235n

Example (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"}'

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;

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';