Basic Usage
Use money() to construct a Money value from a human-readable decimal, number, or raw bigint minor units. The currency code is validated against Intl.NumberFormat at creation time — unrecognised codes throw InvalidCurrencyError.
import { money } from '@vielzeug/coins';
// From decimal string (preferred — lossless)
money('1234.56', 'USD'); // { amount: 123456n, currency: 'USD' }
money('-10.50', 'USD'); // { amount: -1050n, currency: 'USD' }
money('1234', 'JPY'); // { amount: 1234n, currency: 'JPY' } (zero-decimal)
money('1.234', 'KWD'); // { amount: 1234n, currency: 'KWD' } (three-decimal)
// From number (converted via String() — IEEE-754 applies; prefer strings)
money(1234.56, 'USD'); // { amount: 123456n, currency: 'USD' }
// From bigint — raw minor units, passed through as-is
money(123456n, 'USD'); // { amount: 123456n, currency: 'USD' }
// Zero accumulator — use money(0n, currency)
money(0n, 'USD'); // { amount: 0n, currency: 'USD' }
money(0n, 'JPY'); // { amount: 0n, currency: 'JPY' }
// Invalid currency — throws InvalidCurrencyError (extends RangeError)
money('1.00', 'NOTREAL'); // InvalidCurrencyError: Invalid ISO 4217 currency code: "NOTREAL"Money is a plain readonly object — no class, no methods:
type Money = {
readonly amount: bigint; // minor units
readonly currency: string; // validated ISO 4217 code
};Arithmetic
All binary functions (add, subtract) throw CurrencyMismatchError when currencies differ:
import { add, subtract, multiply, divide, abs, negate } from '@vielzeug/coins';
const a = money('100.00', 'USD');
const b = money('30.00', 'USD');
add(a, b); // { amount: 13000n, currency: 'USD' } ($130.00)
subtract(a, b); // { amount: 7000n, currency: 'USD' } ($70.00)
abs(money('-50.00', 'USD')); // { amount: 5000n, currency: 'USD' }
negate(money('10.00', 'USD')); // { amount: -1000n, currency: 'USD' }
// throws CurrencyMismatchError: Currency mismatch: USD and EUR
add(money('10.00', 'USD'), money('10.00', 'EUR'));multiply and divide
Both accept a number | string scalar and an optional RoundingMode (default 'half-away-from-zero'). Use strings for lossless fractional factors.
multiply(money('100.00', 'USD'), '1.5'); // $150.00
multiply(money('1.00', 'USD'), '0.339', 'floor'); // $0.33
multiply(money('1.00', 'USD'), '0.339', 'ceiling'); // $0.34
divide(money('100.00', 'USD'), 3); // $33.33
divide(money('100.00', 'USD'), 3, 'ceiling'); // $33.34
divide(money('100.00', 'USD'), 0); // throws RangeError: Division by zeroRounding Modes
| Mode | Description |
|---|---|
'half-away-from-zero' | Round half away from zero (default) |
'half-even' | Banker's rounding — minimises cumulative error over many operations |
'down' | Truncate toward zero |
'up' | Away from zero |
'floor' | Toward −∞ (down for positives, extra step for negatives) |
'ceiling' | Toward +∞ (extra step for positives, truncate for negatives) |
Allocation
Allocation distributes a Money value across weighted shares with a guarantee: the sum of all shares is always exactly equal to the input. No minor unit is ever lost or gained.
allocate(money, ratios)
Ratios can be numbers or strings. Use strings for lossless decimal weights (e.g. '0.333').
import { allocate } from '@vielzeug/coins';
// Equal split — extra penny to the first share
allocate(money('10.00', 'USD'), [1, 1, 1]);
// → [$3.34, $3.33, $3.33] (sum = $10.00 exactly)
// Weighted split
allocate(money('10.00', 'USD'), [3, 7]);
// → [$3.00, $7.00]
// Decimal string ratios
allocate(money('10.00', 'USD'), ['0.3', '0.7']);
// → [$3.00, $7.00]
// Decimal string ratios that don't sum to 1 — proportions are normalised
allocate(money('7.00', 'USD'), ['0.333', '0.333', '0.334']);
// → [$2.33, $2.33, $2.34] (sum = $7.00 exactly)Uses the Largest Remainder Method: each share gets its floor allocation first, then any remainder units are assigned one-by-one to the shares with the largest fractional parts.
splitEvenly(money, parts)
Sugar over allocate with all-equal weights.
import { splitEvenly } from '@vielzeug/coins';
splitEvenly(money('10.00', 'USD'), 3);
// → [$3.34, $3.33, $3.33]Aggregates
import { clamp, max, min, sum } from '@vielzeug/coins';
const items = [money('1.00', 'USD'), money('2.50', 'USD'), money('0.99', 'USD')];
sum(items); // $4.49
min(items); // $0.99
max(items); // $2.50
sum([]); // throws RangeError: sum requires at least one Money value
// Clamp to an allowed price range
const lo = money('1.00', 'USD');
const hi = money('99.99', 'USD');
clamp(money('0.50', 'USD'), lo, hi); // $1.00 (below minimum)
clamp(money('42.00', 'USD'), lo, hi); // $42.00 (in range)
clamp(money('150.00', 'USD'), lo, hi); // $99.99 (above maximum)Comparison
Most comparison functions throw CurrencyMismatchError on currency mismatch. isEqual is the exception — it returns false when currencies differ, making it safe for .filter() and conditional chains.
import {
compare,
isEqual,
greaterThan,
lessThan,
isZero,
isPositive,
isNegative,
isNonNegative,
isNonPositive,
} from '@vielzeug/coins';
const five = money('5.00', 'USD');
const ten = money('10.00', 'USD');
compare(five, ten); // -1
compare(ten, five); // 1
compare(five, five); // 0
isEqual(five, five); // true
isEqual(five, ten); // false
isEqual(five, money('5.00', 'EUR')); // false — different currency, no throw
greaterThan(ten, five); // true
lessThan(five, ten); // true
isZero(money('0.00', 'USD')); // true
isPositive(five); // true
isNegative(money('-1.00', 'USD')); // true
// Non-strict predicates (inclusive of zero)
isNonNegative(money('0.00', 'USD')); // true (zero or positive)
isNonNegative(money('-1.00', 'USD')); // false
isNonPositive(money('0.00', 'USD')); // true (zero or negative)
isNonPositive(five); // false
// throws CurrencyMismatchError: Currency mismatch: USD and EUR
compare(money('5.00', 'USD'), money('5.00', 'EUR'));Serialization
bigint cannot be serialized by JSON.stringify. Use toJSON / fromJSON to round-trip through JSON safely:
import { toJSON, fromJSON, toDecimal, toNumber } from '@vielzeug/coins';
const price = money('1234.56', 'USD');
// JSON serialization
const serialized = toJSON(price);
// → { amount: '123456', currency: 'USD' } (amount is a string)
JSON.stringify(serialized);
// → '{"amount":"123456","currency":"USD"}'
fromJSON(serialized); // → { amount: 123456n, currency: 'USD' }
// fromJSON rejects non-string amount fields
fromJSON({ amount: 123456 as any, currency: 'USD' }); // TypeError: expected an integer string
fromJSON({ amount: '1.5', currency: 'USD' }); // TypeError: expected an integer string
// Round-trips
fromJSON(toJSON(price)); // equals price
money(toDecimal(price), 'USD'); // equals price
// Decimal string — useful for display or passing to other systems
toDecimal(money(5n, 'USD')); // '0.05'
toDecimal(money(1234n, 'JPY')); // '1234'
// Lossy float — for charting libraries, not arithmetic
toNumber(price); // 1234.56Formatting
format(money, options?)
Produces a locale-aware currency string. Uses bigint arithmetic throughout — exact regardless of amount size.
import { format } from '@vielzeug/coins';
const price = money('1234.56', 'USD');
format(price); // '$1,234.56'
format(price, { locale: 'de-DE' }); // '1.234,56 $'
format(price, { locale: 'fr-FR' }); // '1 234,56 $'
format(price, { style: 'code' }); // 'USD 1,234.56'
format(price, { style: 'name' }); // '1,234.56 US dollars'
format(price, { style: 'narrowSymbol' }); // '$1,234.56' (compact)
// Zero-decimal currencies
format(money('1234', 'JPY')); // '¥1,234'
// Custom fraction digits — set only maximumFractionDigits when you want to truncate
format(price, { maximumFractionDigits: 0 }); // '$1,235'
format(price, { minimumFractionDigits: 3, maximumFractionDigits: 3 }); // '$1,234.560'formatParts(money, options?)
Returns a MoneyFormatPart[] array instead of a joined string. Useful for applying different CSS to each semantic part (symbol, integer, fraction, sign).
import { formatParts } from '@vielzeug/coins';
formatParts(money('1234.56', 'USD'));
// [
// { type: 'currency', value: '$' },
// { type: 'integer', value: '1,234' },
// { type: 'decimal', value: '.' },
// { type: 'fraction', value: '56' },
// ]
formatParts(money('-99.99', 'USD'));
// [
// { type: 'minusSign', value: '-' },
// { type: 'currency', value: '$' },
// { type: 'integer', value: '99' },
// { type: 'decimal', value: '.' },
// { type: 'fraction', value: '99' },
// ]
// Joining all values always equals format():
formatParts(m)
.map((p) => p.value)
.join('') === format(m); // trueCurrency Exchange
exchange() converts a Money value using a provided ExchangeRate. The rate field must be a decimal string — not a number — to avoid IEEE-754 errors in the bigint multiplication.
import { exchange } from '@vielzeug/coins';
import type { ExchangeRate } from '@vielzeug/coins';
// ExchangeRate.from and .to are plain strings — no pre-validation ceremony
const rate: ExchangeRate = { from: 'USD', rate: '0.92', to: 'EUR' };
exchange(money('100.00', 'USD'), rate); // { amount: 9200n, currency: 'EUR' }
exchange(money('100.00', 'USD'), rate, 'floor'); // explicit rounding mode
// Throws CurrencyMismatchError if money.currency !== rate.from
exchange(money('100.00', 'EUR'), rate); // CurrencyMismatchError: Currency mismatch: EUR and USD
// Throws InvalidCurrencyError if rate.to is not a recognised ISO 4217 code
exchange(money('100.00', 'USD'), { from: 'USD', rate: '1.0', to: 'FAKE' }); // InvalidCurrencyError
// Throws RangeError for negative or empty rates
exchange(money('100.00', 'USD'), { from: 'USD', rate: '-0.92', to: 'EUR' }); // RangeError: Exchange rate must be non-negative
exchange(money('100.00', 'USD'), { from: 'USD', rate: '', to: 'EUR' }); // RangeError: Exchange rate must be a non-empty decimal string
// High-precision rates — string parsing avoids float error
const highPrecRate: ExchangeRate = { from: 'USD', rate: '0.847532', to: 'EUR' };
exchange(money('1000.00', 'USD'), highPrecRate); // { amount: 84753n, currency: 'EUR' }Practical Patterns
Cart Total
import { add, format, money, sum } from '@vielzeug/coins';
import type { Money } from '@vielzeug/coins';
const items: Money[] = [money('9.99', 'USD'), money('14.99', 'USD'), money('2.50', 'USD')];
const subtotal = sum(items);
const tax = multiply(subtotal, '0.08');
const total = add(subtotal, tax);
format(total); // '$29.68'Invoice Line Allocation
import { allocate, format, money } from '@vielzeug/coins';
const invoice = money('100.00', 'USD');
const [alice, bob, carol] = allocate(invoice, [50, 30, 20]);
format(alice); // '$50.00'
format(bob); // '$30.00'
format(carol); // '$20.00'
// alice.amount + bob.amount + carol.amount === 10000n (exactly)Multi-Currency Price Display
import { exchange, format, money } from '@vielzeug/coins';
import type { ExchangeRate } from '@vielzeug/coins';
const price = money('50.00', 'USD');
const rates: ExchangeRate[] = [
{ from: 'USD', rate: '0.92', to: 'EUR' },
{ from: 'USD', rate: '0.79', to: 'GBP' },
{ from: 'USD', rate: '149.5', to: 'JPY' },
];
for (const rate of rates) {
console.log(format(exchange(price, rate)));
}
// €46.00
// £39.50
// ¥7,475React Custom Rendering
import { formatParts, money } from '@vielzeug/coins';
function Price({ amount, currency }: { amount: bigint; currency: string }) {
const parts = formatParts(money(amount, currency));
return (
<span>
{parts.map((part, i) =>
part.type === 'fraction' ? (
<sup key={i}>{part.value}</sup>
) : (
<span key={i} className={part.type}>{part.value}</span>
)
)}
</span>
);
}Utilities
withAmount(m, amount)
Creates a new Money with a different raw bigint amount while preserving the source currency. Use this when you compute a bigint externally and need to re-wrap it without re-validating the currency.
import { money, withAmount, toDecimal } from '@vielzeug/coins';
const price = money('9.99', 'USD');
// Compute externally, then wrap back
const rawDoubled = price.amount * 2n;
const doubled = withAmount(price, rawDoubled);
toDecimal(doubled); // '19.98'
// Useful in reduce / fold patterns on raw bigint values
const amounts = [100n, 250n, 75n];
const total = withAmount(
price,
amounts.reduce((a, b) => a + b, 0n),
);
toDecimal(total); // '4.25'isMoney(value)
Type guard that narrows unknown to Money. Checks own-property bigint amount and string currency — prototype-chain properties are rejected.
import { isMoney, toDecimal } from '@vielzeug/coins';
// Narrow untrusted API payloads
function displayPrice(raw: unknown): string {
if (!isMoney(raw)) throw new TypeError('Expected a Money value');
return toDecimal(raw);
}
displayPrice({ amount: 1999n, currency: 'USD' }); // '19.99'
displayPrice({ amount: 9.99, currency: 'USD' }); // throws — amount is float, not bigint
displayPrice(null); // throws
// isMoney does NOT validate the currency code — it only checks shape
isMoney({ amount: 100n, currency: 'FAKE' }); // true — shape matches but code is unvalidatedTyped Error Handling
All currency mismatch errors are CurrencyMismatchError (extends TypeError) and all invalid currency code errors are InvalidCurrencyError (extends RangeError). Use instanceof for structured error handling:
import { CurrencyMismatchError, InvalidCurrencyError, add, money } from '@vielzeug/coins';
try {
add(money('1.00', 'USD'), money('1.00', 'EUR'));
} catch (e) {
if (e instanceof CurrencyMismatchError) {
// e.expected === 'USD', e.received === 'EUR'
console.log(`Expected ${e.expected}, got ${e.received}`);
}
}
try {
money('1.00', 'FAKE');
} catch (e) {
if (e instanceof InvalidCurrencyError) {
console.log('Unknown currency code:', e.code); // 'FAKE'
}
}Both error classes extend built-in error types, so existing instanceof TypeError / instanceof RangeError catch blocks continue to work without any changes.
Rounding to Fewer Decimal Places
Use roundTo() when you need to display a Money value at coarser precision than the currency default (e.g. whole dollars for a summary widget, or 1 decimal place for a chart axis).
places must be in the range 0..currencyDecimals. The function is a pure rounding operation — no currency conversion, no allocation.
import { money, roundTo } from '@vielzeug/coins';
const price = money('1234.56', 'USD');
roundTo(price, 0); // { amount: 1235n, currency: 'USD' } — whole dollars, rounds up
roundTo(price, 1); // { amount: 12346n, currency: 'USD' } — 1 decimal place
roundTo(price, 2); // price unchanged (2 === USD decimal places)
// Explicit rounding mode
roundTo(price, 0, 'floor'); // { amount: 1234n, currency: 'USD' } — truncate
roundTo(price, 0, 'ceiling'); // { amount: 1235n, currency: 'USD' } — always up
// JPY has 0 decimal places — roundTo(m, 0) is always a no-op
const yen = money(1234n, 'JPY');
roundTo(yen, 0) === yen; // true — same reference returned
roundTois for display purposes. Do not feed its output back into allocation or arithmetic — the reduced precision may cause downstream rounding errors.
Working with Other Vielzeug Libraries
With Tempo — format monetary amounts alongside dates in the same pipeline:
import { money, format } from '@vielzeug/coins';
import { formatDate } from '@vielzeug/tempo';
const amount = money('1234.56', 'USD');
const date = new Date();
console.log(`As of ${formatDate(date, 'MMM d, yyyy')}: ${format(amount)}`);
// e.g. "As of Jun 9, 2026: $1,234.56"With Arsenal — combine array utilities with monetary aggregation:
import { sum, money } from '@vielzeug/coins';
import { groupBy } from '@vielzeug/arsenal';
const transactions = [
{ category: 'food', amount: money('12.50', 'USD') },
{ category: 'travel', amount: money('80.00', 'USD') },
{ category: 'food', amount: money('9.75', 'USD') },
];
const byCategory = groupBy(transactions, (t) => t.category);
const foodTotal = sum(byCategory.food.map((t) => t.amount));
// foodTotal = money('22.25', 'USD')With Spell — validate and parse currency input from user forms:
import { money, InvalidCurrencyError } from '@vielzeug/coins';
import { object, string } from '@vielzeug/spell';
const MoneyInput = object({
amount: string().regex(/^\d+(\.\d{1,3})?$/),
currency: string(),
});
const parsed = MoneyInput.parse(formData);
// money() validates the currency code — throws InvalidCurrencyError for unknown codes
const value = money(parsed.amount, parsed.currency);Best Practices
- Prefer decimal strings over numbers when constructing
money()—money('1234.56', 'USD')avoids IEEE-754 rounding before the value ever reaches bigint storage. In development,money()warns via[@vielzeug/coins]when a float has more decimal places than the currency supports. - Use
money(0n, currency)for zero accumulators — it bypasses decimal parsing and is explicit about minor units. - Use
validateCurrencyCode(code)when you want to pre-check an ISO 4217 code without immediately creating aMoneyvalue — it returns the code unchanged or throwsInvalidCurrencyError. This is the same checkmoney()performs internally and results are cached. - Pass
ExchangeRate.from/toas plain strings —money()validates currencies at creation time, andexchange()validatesrate.tobefore returning. - Use
allocate()instead of manualdivide+ rounding whenever distributing a total across multiple parties — it guarantees the shares sum exactly to the original amount. - Use
'half-even'(banker's rounding) in bulk-processing scenarios (batch invoices, statement generation) to minimise cumulative rounding drift. - Never store
toNumber()output and feed it back into arithmetic.toNumber()is lossy — use it only for display and charting libraries. - Pass
ExchangeRate.rateas a string, not a number. The string is parsed into an exact rational fraction; anumberwould introduce float error before the bigint conversion. - Use
sum()instead of a manual reduce overadd()— it validates currency consistency across the entire array upfront, so any mismatch is caught immediately with a clear error rather than failing at a mid-arrayadd()call. - Use
instanceof CurrencyMismatchError/instanceof InvalidCurrencyErrorincatchblocks rather than string-matching error messages — they are stable, typed, and extend built-in error types. - Use
getCurrencyDecimals(code)when building custom formatters or lookup tables that need to know the minor-unit precision for a currency — it is the same callmoney()makes internally and results are cached, so it is cheap to call repeatedly.