Numeric types for JavaScript
This library contains implementations of useful numeric types for JavaScript and TypeScript.
npm install numeric-types
This is an ES module package and it needs Node 22 or later. Use import to
load it. Node 22.12 and later can also load it with require.
Features and status
This is currently an early release.
- Rounding modes: up, down, half down, half even, half up, floor, ceiling and unnecessary
- Scale and precision, applied through a
MathContext - Decimal number representation
Decimalon top ofnumberwith limited precision of 15 digitsBigDecimalfor more precise numbers, with up toNumber.MAX_SAFE_INTEGERdigits- Basic math operations: add, subtract, multiply, divide, remainder and pow
- Integer representation
Integerfor integers betweenNumber.MIN_SAFE_INTEGERandNumber.MAX_SAFE_INTEGERBigIntegerfor large integers, on top of the built-inbiginttype- Math operations, and the bitwise operations over the full range of the type
- The same set of basics for both families:
abs,negate,sign,isZero,minandmax - Conversion between the two versions of a type, and from an integer to a decimal
toNumberandtoJSONon every value, so a number survivesJSON.stringifywith all of its digits
API
In this library all numeric types are immutable, so functions always return a new instance. Each numeric type provides a limited API to support their creation and basic use.
Operations on the number types is provided as separate functions that can be imported. This design is to allow the library to take advantage of tree-shaking.
static NumericType.fromNumber(value: number): NumericTypeCreate an instance of the numeric type from a regular JavaScript number. The number must be finite. The integer types also require a whole number, and
Integerrequires one that is safe, so nothing is rounded or dropped without you knowing.static NumericType.parse(value: string): NumericTypeCreate an instance of the numeric type from a string. Whitespace around the value is ignored. Anything that is not a number in the form the type accepts is rejected, so
parsenever returns a value that is only part of the input. The decimal types also accept e-notation, written with eithereorE, so1.5e30and1.5E+30are the same value.numericType.toString(): stringTurn the numeric type into a string representation that
parseaccepts.A decimal is written in the plain form, such as
120or0.005, while the decimal point stays in the range that anumberwrites plainly, and in e-notation outside of it. The switch happens at the same place as it does for anumber, so1e21is written as1e+21and0.0000001as1e-7. E-notation also keeps a large exponent from building a very long string, asDecimal.parse('1e1000000')is written with its exponent instead of a million zeroes. Both forms keep every digit and the scale of the value.Decimal.parse('1.50').toString(); // '1.50' Decimal.parse('1e20').toString(); // '100000000000000000000' Decimal.parse('1e21').toString(); // '1e+21' Decimal.parse('1e1000000').toString(); // '1e+1000000'numericType.toNumber(): numberGet the nearest
numberto the value. Anumbercan not hold every value that these types can, so digits are lost when the value needs more of them than anumberhas, and a value that is too large becomesInfinity. UsetoStringwhen every digit matters.numericType.toJSON(): stringCalled by
JSON.stringify. JSON has no exact decimal type and a large integer does not survive a JSON number, so the value is written as a string and keeps all of its digits.JSON.stringify({ amount: Decimal.parse('0.10') }); // {"amount":"0.10"}Read the value back with
parse, which accepts whattoJSONwrites.
Every type also implements Symbol.toPrimitive, so a value converts when
JavaScript needs a primitive. Number(value) and a comparison such as
a < b use the number form, and everything else uses the string form. + is
therefore a string join and not math:
`${Decimal.parse('1.50')}` // '1.50'
Number(Decimal.parse('1.50')) // 1.5
'' + Decimal.parse('1.50') // '1.50'
Use the operations below for math, as they keep every digit.
Conversion between types
Each type can be built from the other version of itself, and the decimal types can also be built from an integer.
static BigDecimal.fromDecimal(value: Decimal): BigDecimalstatic Decimal.fromBigDecimal(value: BigDecimal): Decimalstatic BigInteger.fromInteger(value: Integer): BigIntegerstatic Integer.fromBigInteger(value: BigInteger): Integerstatic Decimal.fromInteger(value: Integer): Decimalstatic BigDecimal.fromBigInteger(value: BigInteger): BigDecimal
A conversion keeps the scale of the number, so 1.50 stays 1.50. Going to a
big type always works. Going the other way throws a MathError when the value
needs more digits than the smaller type holds, so nothing is dropped without
you knowing.
import { Decimal, BigDecimal } from 'numeric-types/decimal';
const big = BigDecimal.fromDecimal(Decimal.parse('1.50'));
const back = Decimal.fromBigDecimal(big);
The big types also read the built-in bigint type:
static BigInteger.fromBigInt(value: bigint): BigIntegerstatic BigDecimal.fromBigInt(value: bigint): BigDecimal
Errors
Every failure is reported as a MathError, which is exported from
numeric-types. This includes input that can not be parsed, a value that does
not fit the type, a division by zero, and an operation that mixes two
different types.
import { MathError } from 'numeric-types';
import { Decimal } from 'numeric-types/decimal';
try {
Decimal.parse('not a number');
} catch(ex) {
if(ex instanceof MathError) {
// Handle the invalid input
}
}
MathContext and rounding
This library uses a class named MathContext to support operations such as
setting the scale or precision and rounding a number.
import { MathContext, RoundingMode } from 'numeric-types';
// Create a context that requests 2 digits after the decimal using Half Up rounding
const contextWithScale = MathContext.ofScale(2, RoundingMode.HalfUp);
// Context that requests at max 10 digits of precision
const contextWithPrecision = MathContext.ofPrecision(10, RoundingMode.Ceiling);
Scale is the number of digits after the decimal point, so a scale of 2 turns
1.005 into 1.01. Precision is the number of significant digits, so a
precision of 2 turns 123.456 into 120 and 0.001234 into 0.0012. A
precision must be at least 1.
A context can carry only one of the two. If you build a context with the constructor and set both, the scale is used and the precision is ignored.
Rounding modes
RoundingMode.Down- Round towards zero.RoundingMode.Up- Round away from zero.RoundingMode.HalfDown- Round towards the nearest neighbor, but if in the middle round towards zero.RoundingMode.HalfEven- Round towards the nearest neighbor, but if in the middle round towards the even neighbor.RoundingMode.HalfUp- Round towards the nearest neighbor, but if in the middle round away from zero.RoundingMode.Floor- Round towards negative infinity.RoundingMode.Ceiling- Round towards positive infinity.RoundingMode.Unnecessary- Do not round, instead throw an error if rounding is required.
Type: Decimal
Decimal is an implementation of a numeric type that avoids the rounding
errors common with floating point numbers. There are two versions, Decimal
and BigDecimal where Decimal is limited to safely handling 15 digits and
BigDecimal can handle up to Number.MAX_SAFE_INTEGER digits:
import { Decimal, multiply } from 'numeric-types/decimal';
const a = Decimal.fromNumber(0.1);
const b = Decimal.parse('12');
const ab = multiply(a, b);
console.log(ab.toString());
Types are available for TypeScript:
import { AbstractDecimal, Decimal } from 'numeric-types/decimal';
const decimal: AbstractDecimal<any> = Decimal.fromNumber(0.1);
Decimal throws a MathError when a result needs more digits than a number
can hold exactly. It reports the limit instead of dropping digits, so a result
you receive is always exact. Use BigDecimal for values that need more room.
The two types can not be mixed in one operation. An operation that receives a
Decimal and a BigDecimal throws a MathError.
Constants
Both types carry the common values as static constants. They have no digits after the decimal point, and they are shared instances, so reading one costs nothing:
import { Decimal, add } from 'numeric-types/decimal';
const total = add(Decimal.parse('1.25'), Decimal.ONE);
| Constant | Value |
|---|---|
ZERO |
0 |
ONE |
1 |
MINUS_ONE |
-1 |
TWO |
2 |
TEN |
10 |
A constant belongs to its own type, so use Decimal.ONE with a Decimal and
BigDecimal.ONE with a BigDecimal.
Operations
These operations are available from numeric-types/decimal. Import them
separately like:
import { operationHere, anotherOperation } from 'numeric-types/decimal';
compare(a: DecimalType, b: DecimalType): -1 | 0 | 1Compare two decimal numbers. This method will return
0if the numbers are the same,-1ifais less thanband1ifais greater thanb.isEqual(a: DecimalType, b: DecimalType): booleanGet if two decimal numbers are equal.
isLessThan(a: DecimalType, b: DecimalType): booleanGet if the decimal number
ais less than the numberb.isLessThanOrEqual(a: DecimalType, b: DecimalType): booleanGet if the decimal number
ais less than or equal to the numberb.isGreaterThan(a: DecimalType, b: DecimalType): booleanGet if the decimal number
ais greater than the numberb.isGreaterThanOrEqual(a: DecimalType, b: DecimalType): booleanGet if the decimal number
ais greater than or equal to the numberb.min(a: DecimalType, b: DecimalType): DecimalTypeGet the smaller of two decimal numbers. Two numbers can be equal and still be written with a different scale, and
ais returned in that case.max(a: DecimalType, b: DecimalType): DecimalTypeGet the larger of two decimal numbers. Two numbers can be equal and still be written with a different scale, and
ais returned in that case.isZero(a: DecimalType): booleanGet if a decimal number is zero. The scale does not matter, so
0,0.0and0e10are all zero.sign(a: DecimalType): -1 | 0 | 1Get the sign of a decimal number. Returns
-1for a negative number,0for zero and1for a positive number.toString(a: DecimalType): stringTurn a decimal numbers into its string representation, either in the plain form or in e-notation. See
numericType.toString()above for the form that is used.toNumber(a: DecimalType): numberGet the nearest
numberto a decimal number.scale(a: DecimalType, context: MathContext): DecimalTypeScale the given decimal number according to the specified context. The context can request a scale or a precision.
round(a: DecimalType, roundingMode?: RoundingMode): DecimalTypeRound the given decimal number to a whole number. If the rounding mode is not specified
RoundingMode.HalfUpis used. This is equivalent to callingscalewithMathContext.ofScale(0, roundingMode).abs(a: DecimalType): DecimalTypeGet the absolute value of a decimal number. The scale is kept, so
-1.50becomes1.50.negate(a: DecimalType): DecimalTypeGet a decimal number with its sign flipped. The scale is kept, so
1.50becomes-1.50.add(a: DecimalType, b: DecimalType, context?: MathContext): DecimalTypeAdd two decimal numbers together, optionally specifying a context to be used to adjust the scale of the result.
subtract(a: DecimalType, b: DecimalType, context?: MathContext): DecimalTypeSubtract a decimal number
bfrom the numbera. Optionally specify a context to be used to adjust the scale of the result.multiply(a: DecimalType, b: DecimalType, context?: MathContext): DecimalTypeMultiply two decimal numbers together. Optionally specify a context to be used to adjust the scale of the result.
divide(a: DecimalType, b: DecimalType, context: MathContext): DecimalTypeDivide the number
aby the divisorb. A context is required, as a division rarely has an exact result and the context decides how many digits are kept and how they are rounded.A context that carries neither a scale nor a precision keeps 5 digits after the decimal point and then removes trailing zeroes. A divisor of zero throws a
MathError.remainder(a: DecimalType, b: DecimalType, context?: MathContext): DecimalTypeGet what remains after
ais divided byb. The division truncates towards zero, so the remainder carries the sign ofaand10.5divided by3leaves1.5. The result is exact. A divisor of zero throws aMathError.pow(a: DecimalType, exponent: number, context?: MathContext): DecimalTypeRaise
ato a whole power given as a regular number. A power of zero or more is exact, and every number raised to zero is1.A negative power is a division, so it needs a context that says how many digits to keep. Without one it throws a
MathError, and so does0raised to a negative power.
Without a context, add, subtract, multiply, remainder and pow are
exact, and trailing zeroes are removed from the result. 0.50 + 0.50 is
therefore 1 and not 1.00. Pass a context when the result has to keep a
specific shape.
Type: Integer
Integer is an implementation of a whole number. There are currently two versions
available, Integer which is limited to the range of number and BigInteger
which uses the built-in bigint type to represent larger numbers.
import { Integer, multiply } from 'numeric-types/integer';
const a = Integer.fromNumber(20);
const b = Integer.parse('40');
const ab = multiply(a, b);
console.log(ab.toString());
Types are available for TypeScript:
import { AbstractInteger, Integer } from 'numeric-types/integer';
const integer: AbstractInteger<any> = Integer.fromNumber(1);
Both types accept only whole numbers. Integer.fromNumber(1.5) throws a
MathError instead of rounding, and Integer also rejects a value outside
the safe range of number. Use BigInteger for values that are larger.
The two types can not be mixed in one operation. An operation that receives an
Integer and a BigInteger throws a MathError.
Constants
Both types carry the common values as static constants. They are shared instances, so reading one costs nothing:
import { Integer, add } from 'numeric-types/integer';
const next = add(Integer.parse('41'), Integer.ONE);
| Constant | Value |
|---|---|
ZERO |
0 |
ONE |
1 |
MINUS_ONE |
-1 |
TWO |
2 |
TEN |
10 |
A constant belongs to its own type, so use Integer.ONE with an Integer and
BigInteger.ONE with a BigInteger.
Operations
These operations are available from numeric-types/integer. Import them
separately like:
import { operationHere, anotherOperation } from 'numeric-types/integer';
compare(a: IntegerType, b: IntegerType): -1 | 0 | 1Compare two integers. This method will return
0if the numbers are the same,-1ifais less thanband1ifais greater thanb.isEqual(a: IntegerType, b: IntegerType): booleanGet if two integers are equal.
isLessThan(a: IntegerType, b: IntegerType): booleanGet if the integer
ais less than the numberb.isLessThanOrEqual(a: IntegerType, b: IntegerType): booleanGet if the integer
ais less than or equal to the numberb.isGreaterThan(a: IntegerType, b: IntegerType): booleanGet if the integer
ais greater than the numberb.isGreaterThanOrEqual(a: IntegerType, b: IntegerType): booleanGet if the integer
ais greater than or equal to the numberb.min(a: IntegerType, b: IntegerType): IntegerTypeGet the smaller of two integers.
ais returned when they are equal.max(a: IntegerType, b: IntegerType): IntegerTypeGet the larger of two integers.
ais returned when they are equal.isZero(a: IntegerType): booleanGet if an integer is zero.
sign(a: IntegerType): -1 | 0 | 1Get the sign of an integer. Returns
-1for a negative number,0for zero and1for a positive number.toString(a: IntegerType): stringTurn a integers into its string representation.
toNumber(a: IntegerType): numberGet the nearest
numberto an integer.add(a: IntegerType, b: IntegerType): IntegerTypeAdd two integers together.
subtract(a: IntegerType, b: IntegerType): IntegerTypeSubtract
bfroma.multiply(a: IntegerType, b: IntegerType): IntegerTypeMultiply two integers together.
divide(a: IntegerType, b: IntegerType): IntegerTypeDivide
aby the divisorb. The result is truncated towards zero, so-7 / 2is-3. A divisor of zero throws aMathError.remainder(a: IntegerType, b: IntegerType): IntegerTypeGet what remains after
ais divided byb. The remainder carries the sign ofa, so-7 % 2is-1. Together withdividethis holds:divide(a, b) * b + remainder(a, b) === a. A divisor of zero throws aMathError.exponentiate(a: IntegerType, b: IntegerType): IntegerTypeRaise
ato the power ofb. The exponent must not be negative, as a negative exponent describes a fraction.pow(a: IntegerType, exponent: number): IntegerTypeRaise
ato a whole power given as a regular number. This isexponentiatewith an exponent that does not have to be built as an integer first. The exponent must not be negative.abs(a: IntegerType): IntegerTypeGet the absolute value of the integer.
negate(a: IntegerType): IntegerTypeGet the integer with its sign flipped.
unaryMinus(a: IntegerType): IntegerTypeAnother name for
negate, which the decimal types use as well.
Bitwise operations
These work over the whole range of the type, and not only over the 32 bits
that the JavaScript operators use. A result that no longer fits the type
throws a MathError.
bitwiseAnd(a: IntegerType, b: IntegerType): IntegerTypeCombine two integers with a bitwise and.
bitwiseOr(a: IntegerType, b: IntegerType): IntegerTypeCombine two integers with a bitwise or.
bitwiseNot(a: IntegerType): IntegerTypeInvert every bit of the integer.
leftShift(a: IntegerType, amount: number): IntegerTypeShift the integer to the left by the given number of bits.
signedRightShift(a: IntegerType, amount: number): IntegerTypeShift the integer to the right by the given number of bits, keeping its sign.
Releasing
Releases are automatic. Commit to master with Conventional
Commits messages. Release Please then
keeps a release pull request open with the next version and the changelog
entries. Merge that pull request to tag the release and publish the package to
npm.
The npm publish uses trusted
publishing, so there is no npm
token in this repository. The publish job gets a short-lived credential from
npm with an OpenID Connect token. The trusted publisher on npmjs.com is set to
this repository and to the workflow file release.yml. If you move or rename
that workflow, change the trusted publisher settings at the same time.