Decimalish

is an arbitrary-precision decimal (aka “BigNumber”) library for JavaScript and TypeScript. How is this different from regular numbers and why would you need such a thing? Consider this surprising fact about regular numbers:

0.1 + 0.2 != 0.3
0.1 + 0.2 == 0.30000000000000004

This isn't yet another JavaScript quirk, but an unfortunate pitfall of nearly all numbers represented by computers.

While we read numbers in decimal, computers read binary and must convert. Information can be lost when converting a fixed number of bits and yield confusing results. In finance or engineering these errors are simply unacceptable.

Decimalish addresses exactly this concern. It removes the need to convert by directly representing numbers in decimal.

It's also unconstrained by size so it can represent exact numbers with arbitrarily high precision (significant digits or decimal places).

So what’s the catch? Well speed for one, computers are specifically designed to make working with floating point numbers fast. While nowhere close to native speed, Decimalish is unlikely to be your program’s bottleneck.

Then there's how you use them. While regular numbers can use the familiar operators (+, *, ==), Decimalish cannot and offers equivalent functions in their place (add(), mul(), eq()).

Finally there's how they’re represented. Like regular numbers, Decimalish offers an immutable primitive. However …it’s a string… hence the –ish. Decimalish decimals are a specific format of numeric string. While this has its advantages, ideally decimal could be its own primitive; but that’s just not JavaScript.

Get started

Decimalish can be used anywhere you use JavaScript. It supports decades-old browsers, modern module-aware Node.js, and web compilers like Webpack. It comes with TypeScript definitions in the box.

For most, install decimalish via npm:

npm install decimalish

Otherwise, find a UMD module on your CDN of choice:

<script src="https://unpkg.com/decimalish"></script>

Why use Decimalish?

"BigDecimal" arbitrary-precision decimal arithmetic libraries are nothing new. Some programming languages like Java and Python come with one built-in. There are decades-old standards to consult. In JavaScript there are many existing decimal libraries, such as the very popular Big.js, as well as a proposal to add a native BigDecimal type. So why choose Decimalish?

Simply put, Decimalish is easy to use, runs everywhere without dependencies or polyfills, reduces common mistakes, and feels JavaScript native, all while keeping a light footprint.

Lightweight

Decimalish is smaller than any library with comparable features. The entire library is 5KB minified and 2.3KB gzipped. Even better, Decimalish supports tree shaking so you only bundle what you use, as little as 0.45KB.

See how this compares to other libraries.

Functional API

All methods in Decimalish's API are provided as top level functions, not prototype methods. This maintains similarity to the built-in Math module, enables tree-shaking, and works well with functional utility libraries like ramda or lodash.

Native primitive type

Most BigDecimal libraries introduce a Decimal type as an Object, which is potentially mutable, not comparable, and often require writing bulky code with repeated calls to constructors. Decimalish’s decimal type, much like the built in number, is an immutable primitive …because it is a string.

A decimal can be used as an object key, compared for equality, safely cached, written to or read from a JSON file, printed to a console or debugger, or anything else you can do with a string.

No special values

Unlike other BigDecimal libraries, Decimalish does not support the "special values" NaN, Infinity, or -0. Forgetting to handle these special values can be a common source of bugs, so that’s one less thing to worry about.

Operations that cannot return a finite decimal value will throw an error (such as "DIV_ZERO").

No implicit rounding

Many BigDecimal libraries automatically round the result of every operation if too bigger, too smaller, or too high precision based on some globally defined config. This can be confusing, cumbersome to configure, and another common source of bugs.

Decimalish almost always returns exact results, only rounding when it must (such as non-terminating division) and always allowing locally configured behavior without any global state.

No trailing zeros

Some BigDecimal libraries attempt to visually preserve precision after an operation by adding trailing zeros. While this can be useful for quick number formatting, this conflates mathematical value with presentation, require multiple kinds of equality (is 1.0 equal to 1?), and sometimes operations such as multiple result in surprising results and thus, you guessed it, another source of bugs.

Decimalish's decimal() constructor, and all other math functions always return canonical normalized decimal values without any leading or trailing zeros.

Places or precision

When determining how many digits should be in a rounded value, most BigDecimal libraries only interpret this as either decimal places or precision (significant digits). It's not always clear which based on reading code alone.

Decimalish offers both for all methods that might round with an easy to read API, alongside a rich set of rounding and division modes.

Extensible

Decimalish exposes the core functions it uses to convert between decimal string values and an internal normalized form, making it straightforward to introduce new operations and functionality on an equal footing to Decimalish’s own API.

API

Types

Arithmetic

Comparison

Magnitude

Rounding

Et cetera

Types

Decimal type

type decimal

A decimal is represented as a numeric string, allowing arbitrary precision. It is a subtype of numeric string, that is all decimal types are numeric strings, but not all numeric strings are decimals.

Convert to decimal

decimal(valueunknown): decimal

Converts any numeric value to a decimal.

Throws a "NOT_NUM" Error if the provided value is not numeric and cannot be translated to decimal.

Note: unlike number, decimal cannot represent Infinity, NaN, or -0.

Is decimal?

isDecimal(valueunknown): value is decimal

Returns true if the provided value is a decimal value.

A value is decimal if it is a numeric string in a canonical decimal form.

Is numeric value?

isNumeric(valueunknown): value is Numeric

Returns true if the provided value is a (finite) Numeric value.

A value is considered numeric if it can be coerced to a numeric string.

Is integer?

isInteger(valueunknown): boolean

Returns true if the provided value is an integer numeric value.

Similar to Number.isInteger(), but works with decimal or any other Numeric value. This is most useful when working with high precision values where Number.isInteger() could lose precision, inadvertently remove fractional information, and then come to an incorrect result.

Numeric value

type Numeric

The Numeric type represents all numeric values which could be coerced to a number: numbers, bigint, boolean, NumericString (including decimal), and NumericObject which have a numeric primitive value.

Numeric string

type NumericString

The NumericString type represents strings that can be parsed as a decimal number.

This does not include hex, octal, binary, or any other non-decimal base numeric strings.

Numeric object

type NumericObject

The NumericObject type represents objects with a Numeric primitive value, either by providing a valueOf() method which returns number or a toString() method which returns NumericString.

Arithmetic

Add (+)

add(aNumeric, bNumeric): decimal

Adds two numeric values as a decimal result. Used as the replacement of the plus (+) operator.

Subtract (-)

sub(aNumeric, bNumeric): decimal

Subtracts the numeric b from the numeric a, returning a decimal result. Used as the replacement of the minus (-) operator.

Multiply (*)

mul(aNumeric, bNumeric): decimal

Multiplies two numeric values as a decimal result. Used as the replacement of the times (*) operator.

Divide (/)

div(dividendNumeric, divisorNumeric, rules?: RoundingRules): decimal

Returns the result of dividing dividend by divisor as a decimal. Used as the replacement of the division (/) operator.

Defaults to 34 digits of precision and the "half even" rounding mode, configurable by providing rounding rules.

Divide and remainder

divRem(dividendNumeric, divisorNumeric, rules?: RoundingRules):
[quotientdecimal, remainderdecimal]

Divides two numeric values to a given places or precision returning both the quotient and the remainder while satisfying the two conditions:

  • dividend = divisor * quotient + remainder
  • abs(remainder) < abs(divisor).

However there is not only one quotient and remainder which satisfies these conditions. A choice of the sign of the remainder (via RoundingMode) and precision of the quotient can be provided via rules.

All rounding modes may be used and these conditions will be satisfied.

Result of dividing 10 by 3 with different signs and some rounding modes:

Example Note
divRem(10, 3, { mode: "down" }) === [ "3", "1" ] The remainder has the same sign as the dividend
divRem(10, -3, { mode: "down" }) === [ "-3", "1" ] "down" is the default rounding mode
divRem(-10, 3, { mode: "down" }) === [ "-3", "-1" ]
divRem(-10, -3, { mode: "down" }) === [ "3", "-1" ]
divRem(10, 3, { mode: "floor" }) === [ "3", "1" ] The remainder has the same sign as the divisor
divRem(10, -3, { mode: "floor" }) === [ "-4", "-2" ]
divRem(-10, 3, { mode: "floor" }) === [ "-4", "2" ]
divRem(-10, -3, { mode: "floor" }) === [ "3", "-1" ]
divRem(10, 3, { mode: "euclidean" }) === [ "3", "1" ] The remainder is always positive
divRem(10, -3, { mode: "euclidean" }) === [ "-3", "1" ]
divRem(-10, 3, { mode: "euclidean" }) === [ "-4", "2" ]
divRem(-10, -3, { mode: "euclidean" }) === [ "4", "2" ]

Divide to integer

divInt(dividendNumeric, divisorNumeric, rules?: RoundingRules): decimal

Returns the integer result of dividing dividend by divisor using truncated (round "down") division by default.

The remainder can be found using rem(). If you need both the quotient and the remainder, use divRem().

Remainder (%)

rem(dividendNumeric, divisorNumeric, rules?: RoundingRules): decimal

Returns the remainder of dividing dividend by divisor using truncated (round "down") division by default. The result always has the same sign as the first argument (or 0). Used as the replacement of the remainder (%), aka modulo, operator.

Note that rem() and div() use different default division rounding rules and should not be used together. The quotient can be found using divInt() instead. If you need both the quotient and the remainder, use divRem().

Modulo

mod(aNumeric, bNumeric): decimal

Returns the modulo of dividing a by b using floored (round "floor") division. The result always has the same sign as the second argument (or 0).

Note: this is not the same as the % (remainder) operator. Use rem() for an equivalent to %.

Power (Exponent)

pow(baseNumeric, exponentNumeric): decimal

Raises base to the power exponent, where exponent must be a positive whole number.

Square root

sqrt(valueNumeric, rules?: RoundingRules): decimal

Returns the square root of value as a decimal.

Defaults to 34 digits of precision using the "half even" rounding mode, configurable by providing rounding rules.

Comparison

Equals (==)

eq(aNumeric, bNumeric): booleanCompares two numeric values and returns true if they are equivalent.

Greater than (>)

gt(aNumeric, bNumeric): booleanCompares two numeric values and returns true if a is greater than b.

Greater than or equals (≥)

gte(aNumeric, bNumeric): booleanCompares two numeric values and returns true if a is greater than or equal to b.

Less than (<)

lt(aNumeric, bNumeric): booleanCompares two numeric values and returns true if a is less than b.

Less than or equals (≤)

lte(aNumeric, bNumeric): booleanCompares two numeric values and returns true if a is less than or equal to b.

Compare

cmp(aNumeric, bNumeric): 1 | -1 | 0

Compares two numeric values and returns 1 if a is greater than b, -1 if b is greater than a, and 0 if a and b are equivalent.

Note: This is equivalent to, but much faster than, sign(sub(a, b)).

Magnitude

Absolute value

abs(valueNumeric): decimalReturns a decimal with the same value but always positive.

Negate

neg(valueNumeric): decimalReturns a decimal with the same value but an opposite sign.

Sign

sign(valueNumeric): 1 | -1 | 0

Returns a number indicating the sign of the provided value. A 1 for positive values, -1 for negative, or 0 for zero.

Note: decimal does not represent negative zero.

Number of decimal places

places(valueNumeric): numberReturns the number of significant digits after the decimal point.

Number of digits

precision(valueNumeric): numberReturns the number of significant digits of the provided value.

Order of magnitude

scale(valueNumeric): number

Returns the scale, or order of magnitude, of the provided value. Equivalent to the exponent when the value is printed with toExponential().

Move decimal point

movePoint(valueNumeric, placesNumeric): decimal

Returns the value with the decimal point to the right a relative number of places. Negative values of places moves the decimal point to the left.

Note: This is equivalent to, but much faster than, mul(value, pow(10, places)).

Rounding

Round

round(valueNumeric, rules?: RoundingRules): decimal

Rounds a numeric value according to the provided RoundingRules, defaulting to { places: 0, mode: "half even" }.

The default "half even" rounding mode is different from the behavior of JavaScript's Math.round() which uses "half ceil". It is also different from many other languages round() function which most typically use "half up". These or any other rounding mode may be provided to match this behavior. For example, to match Math.round():

round(value, { mode: "half ceil" })

Round and remainder

roundRem(valueNumeric, rules?: RoundingRules):
[roundeddecimal, remainderdecimal]

Rounds a numeric value according to the provided RoundingRules, defaulting to { places: 0, mode: "half even" }.

Floor

floor(valueNumeric): decimal

Rounds down to the nearest whole number in the direction of -Infinity.

Note: Equivalent to round(value, { mode: "floor" })

Ceiling

ceil(valueNumeric): decimal

Rounds up to the nearest whole number in the direction of Infinity.

Note: Equivalent to round(value, { mode: "ceil" })

Truncate to integer

int(valueNumeric): decimal

Returns the integer part of a number by rounding to the nearest whole number in the direction of 0, also known as truncation.

Note: Equivalent to round(value, { mode: "down" })

Integer and fractional parts

intFrac(valueNumeric):
[integerdecimal, fractionaldecimal]

Returns the integer and fractional parts of a number by rounding to the nearest whole number in the direction of 0 and including the remainder.

Maximum

max(...valuesNumeric[]): decimalReturns the maximum of the provided values as a decimal.

Minimum

min(...valuesNumeric[]): decimalReturns the minimum of the provided values as a decimal.

Constrain

clamp(valueNumeric, lowNumeric, highNumeric): decimalConstrains value between low and high values.

Configure rounding

interface RoundingRules
places?: Numeric

Decimal places

The number of decimal places to round to. Negative places rounds to higher integer places. Only one of places or precision can be provided.

For most functions, the default is 0, rounding to an integer whole number.

precision?: Numeric

Significant figures

Rounds a result to contain this number of significant digits. Only one of places or precision can be provided.

mode?: RoundingMode

Rounding mode

If a result needs to be rounded to meet the expected number of decimal places or significant digits, the rounding mode determines which direction to round towards.

The default rounding mode, unless stated otherwise, is "half even".

Round method

type RoundingModeAn enum of possible ways to perform rounding.
"up"

Round up

Rounds a result up, away from zero, to the value with a larger absolute value.

"down"

Round down

Rounds a result down, towards zero, to the value with a smaller absolute value.

"ceil"

Ceiling

Rounds a result up, towards Infinity, to the value with the larger signed value.

"floor"

Floor

Rounds a result down, towards -Infinity, to the value with the smaller signed value.

"half up"

Round half up

Rounds a result towards the nearest neighboring value, otherwise "up" if exactly between the two.

"half down"

Round half down

Rounds a result towards the nearest neighboring value, otherwise "down" if exactly between the two.

"half ceil"

Round half ceiling

Rounds a result towards the nearest neighboring value, otherwise "ceil" if exactly between the two.

"half floor"

Round half floor

Rounds a result towards the nearest neighboring value, otherwise "floor" if exactly between the two.

"half even"

Round half towards even

Rounds a result towards the nearest neighboring value, otherwise towards the value with an even least significant digit if exactly between the two.

This is particularly useful to avoid aggregated bias when adding together multiple rounded values as there is an equal chance of rounding up or down.

"euclidean"

Euclidean division

When used with division, produces a result where the remainder is always a positive value, regardless of the signs of the dividend or divisor.

This is particularly useful when provided to rem() to create a version of modulo which always results in a positive number, which is the typical mathematical definition even though most programming languages offer different definitions.

When used with rounding, this is an alias for "floor".

"exact"

Assert exact result

Asserts that applying the provided rounding rules would not result in a rounded value, otherwise throws a "INEXACT" error.

This is particularly useful to ensure a set of operations remains within an expected precision or decimal places, or that division does not result in a non-terminating repeating decimal.

Et cetera

Numeric → number

toNumber(valueNumeric): number

Converts a Numeric value (including decimal) to a JavaScript number.

Throws an "INEXACT" Error if the converting the value would lead to a loss of precision. To convert to a number while allowing precision loss, use the native Number(value) function.

Fixed notation

toFixed(valueNumeric, rules?: RoundingRules): NumericString

A string representation of the provided Numeric value using fixed notation. Uses rules to specify precision or places and the rounding mode should that result in fewer digits.

Scientific notation

toExponential(valueNumeric, rules?: RoundingRules): NumericString

A string representation of the provided Numeric value using exponential scientific notation. Uses rules to specify precision or places and the rounding mode should that result in fewer digits.

decimal → Elements

deconstruct(valueunknown):
[sign1 | -1 | 0, digitsstring, scalenumber, precisionnumber]

Given a numeric value, deconstruct to normalized representation of a decimal: a [sign, digits, scale, precision] tuple.

Functions within this library internally operate on the normalized representation before converting back to a canonical decimal via construct(). These functions are exported to enable user-defined mathematical functions on the decimal type.

  • sign: Either 1 for a positive number, -1 for a negative number, or 0 for 0.
  • digits: A string of significant digits expressed in scientific notation, where the first digit is the ones place, or an empty string for 0.
  • scale: The power of ten the digits is multiplied by, or 0 for 0.
  • precision: The number of digits found in digits (e.g. number of significant digits). Yes this is redundant information with digits.length but it is convenient to access directly.

Elements → decimal

construct(sign1 | -1 | 0, digitsstring, scalenumber): decimal

Construct a decimal from an internal representation

Given a decimal's decomposed representation, return a canonical decimal value.

Errors

type ErrorCode

All errors thrown will include a .code property set to one of the possible values of ErrorCode as well as a link to documentation describing the error.

Detect this property in a catch clause to provide customized error handling behavior. For example, to re-introduce Infinity as a result of division:

function customDivide(a: Numeric, b: Numeric): decimal {
  try {
    return div(a, b)
  } catch (error) {
    if (error.code === "DIV_ZERO") {
      return sign(a) * Infinity
    }
    throw error
  }
}
"NOT_NUM"

Not a number

Thrown by any function when a value provided to a Numeric argument is not numeric or finite; providing Infinity or NaN will throw this error.

"NOT_INT"

Not an integer

Thrown when an integer was expected in an argument or property but not received. For instance, the RoundingRules.places and RoundingRules.precision fields require integers.

"NOT_POS"

Not positive

Thrown when a positive number was expected in an argument or property but not received. For instance, the exponent in pow() must be a positive integer.

"NOT_MODE"

Unknown rounding mode

Thrown when a value provided to mode of RoundingRules is not one of the expected options of RoundingMode.

"NOT_BOTH"

Cannot provide both

Thrown when both RoundingRules.places and RoundingRules.precision fields are simutaneously provided. Only one of these fields may be provided per use.

"INEXACT"

Inexact result

Thrown when an operation would return an inexact result and the provided RoundingMode was "exact".

Also thrown by toNumber() when converting a Numeric would result in loss of precision.

"DIV_ZERO"

Divide by zero

Thrown when attempting to divide by zero, in which case there is no well defined result. This behavior is different from JavaScript which may return Infinity or NaN, however Decimalish does not support these non-finite special values.

"SQRT_NEG"

Square root of negative

Thrown when attempting to square root a negative number, in which case there is no real number result. This behavior is different from JavaScript which returns NaN, however Decimalish does not support this special value.

FAQ

TK

Why doesn't Decimalish support -0?

Negative zero (-0) is a corner case of floating point numbers and frequent source of confusion...

What's the difference between remainder and modulo?

Decimalish provides the rem() function as a decimal friendly version of % which uses the same behavior as JavaScript (round down truncation). While JavaScript officially calls this the "remainder" operator, it's often referred to as the modulus or modulo operator. However there are many potential ways to define modulo and standard math definitions and different programming languages differ in how they choose this definition.

https://en.wikipedia.org/wiki/Modulo_operation#Variants_of_the_definition