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.
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>
"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.
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.
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.
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.
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"
).
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.
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.
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.
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.
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.
isInteger(value: unknown): 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.
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.
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.
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
.
div(dividend: Numeric, divisor: Numeric, 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
.
divRem(dividend: Numeric, divisor: Numeric, rules?: RoundingRules):
[quotient: decimal, remainder: decimal]
Divides two numeric values to a given places or precision returning both the quotient and the remainder while satisfying the two conditions:
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" ]
|
rem(dividend: Numeric, divisor: Numeric, 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()
.
sqrt(value: Numeric, 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
.
places(value: Numeric): number
Returns the number of significant digits after the decimal point.
precision(value: Numeric): number
Returns the number of significant digits of the provided value.
scale(value: Numeric): number
Returns the scale, or order of magnitude, of the provided value.
Equivalent to the exponent when the value is printed with toExponential()
.
round(value: Numeric, 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" })
roundRem(value: Numeric, rules?: RoundingRules):
[rounded: decimal, remainder: decimal]
Rounds a numeric value according to the provided RoundingRules
, defaulting
to { places: 0, mode: "half even" }
.
interface RoundingRules
places?: Numeric
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
Rounds a result to contain this number of significant digits. Only one of
places
or precision
can be provided.
mode?: RoundingMode
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"
.
type RoundingMode
An enum of possible ways to perform rounding.
"half up"
Rounds a result towards the nearest neighboring value, otherwise "up"
if
exactly between the two.
"half down"
Rounds a result towards the nearest neighboring value, otherwise "down"
if exactly between the two.
"half ceil"
Rounds a result towards the nearest neighboring value, otherwise "ceil"
if exactly between the two.
"half floor"
Rounds a result towards the nearest neighboring value, otherwise "floor"
if exactly between the two.
"half 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"
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"
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.
toFixed(value: Numeric, 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.
toExponential(value: Numeric, 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.
deconstruct(value: unknown):
[sign: 1 | -1 | 0, digits: string, scale: number, precision: number]
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.
construct(sign: 1 | -1 | 0, digits: string, scale: number): decimal
Construct a decimal from an internal representation
Given a decimal's decomposed representation, return a canonical decimal value.
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"
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"
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"
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"
Thrown when a value provided to mode
of RoundingRules
is not one of the
expected options of RoundingMode
.
"NOT_BOTH"
Thrown when both RoundingRules.places
and RoundingRules.precision
fields are simutaneously provided. Only one of these fields may be provided
per use.
"INEXACT"
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"
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"
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.
TK
Negative zero (-0
) is a corner case of floating point numbers and frequent
source of confusion...
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