Skip to content

BigDecimal

BigDecimal represents a decimal number with arbitrary precision. It exists to avoid the rounding errors of JavaScript’s binary floating-point number, where 0.1 + 0.2 === 0.30000000000000004. When you are working with money, financial calculations, or any domain where exactness matters, use BigDecimal instead of number. A BigDecimal stores an arbitrary-precision integer (value, a bigint) together with a scale (the number of digits after the decimal point).

import { BigDecimal } from "effect"
// Floating point: wrong
console.log(0.1 + 0.2) // 0.30000000000000004
// BigDecimal: exact
const sum = BigDecimal.sum(
BigDecimal.fromStringUnsafe("0.1"),
BigDecimal.fromStringUnsafe("0.2")
)
console.log(BigDecimal.format(sum)) // "0.3"

fromStringUnsafe parses a decimal literal exactly, and BigDecimal.sum adds two values with no loss of precision. BigDecimal.format renders the result back to a string.

The safest constructors return an Option, because not every input is a valid decimal:

import { BigDecimal } from "effect"
// From a string — None if the string is not a valid number
const fromStr = BigDecimal.fromString("123.45") // Some(123.45)
const invalid = BigDecimal.fromString("nope") // None
// From a number — None if not finite (NaN / Infinity)
const fromNum = BigDecimal.fromNumber(123.45)

When you control the input (e.g. a hard-coded literal) the *Unsafe variants return a BigDecimal directly and throw on bad input:

import { BigDecimal } from "effect"
const price = BigDecimal.fromStringUnsafe("19.99")

Note the v4 convention: the throwing variant carries the Unsafe suffix (fromStringUnsafe), while the total version returns an Option (fromString).

sum, subtract, and multiply are exact and return a BigDecimal. Division returns an Option, because dividing by zero has no result:

import { BigDecimal, Option } from "effect"
const a = BigDecimal.fromStringUnsafe("10.5")
const b = BigDecimal.fromStringUnsafe("3")
console.log(BigDecimal.format(BigDecimal.sum(a, b))) // "13.5"
console.log(BigDecimal.format(BigDecimal.subtract(a, b))) // "7.5"
console.log(BigDecimal.format(BigDecimal.multiply(a, b))) // "31.5"
// divide is partial: None when dividing by zero
const quotient = BigDecimal.divide(a, b)
console.log(Option.map(quotient, BigDecimal.format))
// { _id: 'Option', _tag: 'Some', value: '3.5' }
console.log(BigDecimal.divide(a, BigDecimal.fromStringUnsafe("0")))
// { _id: 'Option', _tag: 'None' }

BigDecimal.round rounds to a given number of decimal places using a rounding mode:

import { BigDecimal } from "effect"
const value = BigDecimal.fromStringUnsafe("3.14159")
// Round to 2 decimal places
console.log(BigDecimal.format(BigDecimal.round(value, { scale: 2 })))
// "3.14"

Use BigDecimal.equals for value equality. Because comparison is numeric, values with different scales but the same magnitude are equal:

import { BigDecimal } from "effect"
console.log(
BigDecimal.equals(
BigDecimal.fromStringUnsafe("1.0"),
BigDecimal.fromStringUnsafe("1.00")
)
) // true

Modelling currency is the canonical use case. Keeping amounts as BigDecimal guarantees totals add up to the cent.

import { BigDecimal } from "effect"
const lineItems = ["19.99", "5.49", "12.00"].map(BigDecimal.fromStringUnsafe)
// sumAll folds a collection without intermediate rounding
const subtotal = BigDecimal.sumAll(lineItems)
const taxRate = BigDecimal.fromStringUnsafe("0.08")
// Round the tax to 2 decimal places (cents)
const tax = BigDecimal.round(BigDecimal.multiply(subtotal, taxRate), { scale: 2 })
const total = BigDecimal.sum(subtotal, tax)
console.log(BigDecimal.format(subtotal)) // "37.48"
console.log(BigDecimal.format(total)) // subtotal plus rounded tax, exact to the cent

For validating and persisting BigDecimal values at the edges of your system, the Schema module provides matching codecs.