dev-resources.site
for different kinds of informations.
Building a Type-Safe Money Handling Library in TypeScript
After years of wrestling with money-related bugs in financial applications, from rounding errors in e-commerce platforms to currency conversion mishaps in international payment systems, I decided to tackle these challenges head-on. The result is @thesmilingsloth/money-utils
, a TypeScript library that brings bank-grade money handling to JavaScript applications. In this post, I'll share the journey, technical decisions, and lessons learned while building it.
The Money Problem in Software Engineering
Money seems simple at first glance—it's just numbers, right? But as Martin Fowler noted in his patterns of enterprise application architecture, money is one of the most complex domains in software engineering. Here's why:
The Floating-Point Trap
JavaScript's number type uses IEEE 754 floating-point arithmetic, which leads to infamous precision issues:
// The classic example
0.1 + 0.2; // => 0.30000000000000004
// But it gets worse with multiple operations
0.1 + 0.2 + 0.3 + 0.4 + 0.5; // => 1.5000000000000002
(0.1 * 100 + 0.2 * 100) / 100; // => 0.30000000000000004
In financial applications, these tiny discrepancies can accumulate into significant errors. Imagine calculating interest rates or processing thousands of transactions—the errors compound quickly.
Real-World Horror Stories
I've seen these issues manifest in production systems:
- An e-commerce platform losing thousands of dollars due to rounding errors in bulk discounts
- A payment processor having mismatched totals because of currency conversion rounding
- An accounting system showing different balances depending on the calculation path
The Solution: @thesmilingsloth/money-utils
Our library addresses these challenges with several key features:
1. Precise Decimal Arithmetic
We chose decimal.js as our arithmetic engine after careful evaluation. It provides the precision needed for financial calculations while maintaining good performance:
import { Money } from "@thesmilingsloth/money-utils";
// Precise calculations, even with complex operations
const price = new Money("99.99", "USD");
const quantity = 2;
// All operations maintain precision
const subtotal = price.multiply(quantity);
const tax = subtotal.multiply("0.2"); // 20% tax
const total = subtotal.add(tax);
console.log(total.toString()); // "$239.98"
2. Type-Safe Currency Operations
The library treats currencies as first-class citizens, with built-in support for major currencies and custom currency definitions:
import { Money, Currency } from "@thesmilingsloth/money-utils";
// Built-in currencies with sensible defaults
const usd = Currency.USD; // Decimals: 2, Symbol: $
const eur = Currency.EUR; // Decimals: 2, Symbol: €
const jpy = Currency.JPY; // Decimals: 0, Symbol: ¥
const btc = Currency.BTC; // Decimals: 8, Symbol: ₿
// Example of built-in currency configurations
const USDDefaults = {
name: "US Dollar",
code: "USD",
symbol: "$",
symbolPosition: "prefix",
decimals: 2,
minorUnits: "100",
decimalSeparator: ".",
thousandsSeparator: ",",
isCrypto: false,
};
const BTCDefaults = {
name: "Bitcoin",
code: "BTC",
symbol: "₿",
symbolPosition: "prefix",
decimals: 8,
minorUnits: "100000000",
decimalSeparator: ".",
thousandsSeparator: ",",
isCrypto: true,
};
// Custom currency support
Currency.register({
name: "Corporate Loyalty Points",
code: "POINTS",
symbol: "⭐",
symbolPosition: "prefix",
decimals: 0,
minorUnits: "1",
decimalSeparator: ".",
thousandsSeparator: ",",
isCrypto: false,
});
// Type-safe operations prevent mixing currencies
const dollars = new Money("100.00", "USD");
const euros = new Money("100.00", "EUR");
// This won't compile - TypeScript prevents currency mixing
// const invalid = dollars.add(euros);
3. Configurable Options with Sensible Defaults
One of our key design decisions was providing sensible defaults while allowing customization:
// Default options for Money instances
const defaultMoneyOptions = {
decimals: undefined, // Uses the currency's default decimals
displayDecimals: undefined, // Uses the currency's default decimals
roundingMode: ROUNDING_MODE.ROUND_HALF_UP,
symbol: undefined, // Uses the currency's default symbol
};
// Using defaults - clean and simple
const simple = new Money("100", "USD");
console.log(simple.toString()); // "$100.00"
// Custom configuration when needed
const custom = new Money("100.555", "USD", {
decimals: 3, // Store with 3 decimal places
displayDecimals: 2, // Display with 2 decimal places
roundingMode: ROUNDING_MODE.ROUND_DOWN,
symbol: "US$", // Custom symbol
});
console.log(custom.toString()); // "US$100.55"
4. Comprehensive Formatting Options
Our formatting engine goes beyond basic toString() operations:
const amount = new Money("1234567.89", "USD");
// Basic formatting with defaults
console.log(amount.toString()); // "$1,234,567.89"
console.log(amount.formattedValue()); // "1,234,567.89"
console.log(amount.formattedValueWithSymbol()); // "$1,234,567.89"
// Locale-aware formatting
console.log(amount.toLocaleString()); // Uses browser's default locale
console.log(amount.toLocaleString("en-US")); // "$1,234,567.89"
console.log(amount.toLocaleString("de-DE")); // "1.234.567,89 $"
console.log(amount.toLocaleString("ja-JP")); // "¥1,234,567.89"
// Advanced formatting options
console.log(
amount.toLocaleString("en-US", {
style: "currency",
currencyDisplay: "name",
})
); // "1,234,567.89 US dollars"
Advanced Features and Edge Cases
1. Configurable Rounding Behavior
import { Money, ROUNDING_MODE } from "@thesmilingsloth/money-utils";
const amount = new Money("100.555", "USD", {
roundingMode: ROUNDING_MODE.ROUND_HALF_UP,
decimals: 2,
});
// Different rounding modes for different use cases
const roundDown = new Money("100.555", "USD", {
roundingMode: ROUNDING_MODE.ROUND_DOWN,
});
console.log(roundDown.toString()); // "$100.55"
const roundUp = new Money("100.555", "USD", {
roundingMode: ROUNDING_MODE.ROUND_UP,
});
console.log(roundUp.toString()); // "$100.56"
2. Cryptocurrency Support
// Bitcoin with 8 decimal places
const bitcoin = new Money("1.23456789", "BTC");
console.log(bitcoin.toString()); // "₿1.23456789"
// Ethereum with 18 decimal places
Currency.register({
name: "Ethereum",
code: "ETH",
symbol: "Ξ",
symbolPosition: "prefix",
decimals: 18,
minorUnits: "1000000000000000000",
decimalSeparator: ".",
thousandsSeparator: ",",
isCrypto: true,
});
const eth = new Money("1.234567890123456789", "ETH");
3. Complex Allocation Scenarios
// Investment profit distribution with different stakeholder percentages
const profit = new Money("1000000.00", "USD");
const shares = profit.allocate([
15, // 15% - Founder
30, // 30% - Operations
20, // 20% - Development
35, // 35% - Investors
]);
console.log(shares.map((s) => s.toString()));
// [
// "$150,000.00",
// "$300,000.00",
// "$200,000.00",
// "$350,000.00"
// ]
Technical Decisions and Lessons Learned
- Type Safety is Non-Negotiable: TypeScript's type system caught countless potential bugs during development. For example:
// This won't compile - type safety prevents invalid operations
const money = new Money("100.00", "USD");
const result = money.multiply("invalid");
- Immutability Prevents Bugs: All operations return new instances, preventing accidental mutations:
const original = new Money("100.00", "USD");
const doubled = original.multiply(2);
console.log(original.toString()); // Still "$100.00"
- Default Configurations Matter: We carefully chose sensible defaults while allowing customization:
// Uses currency defaults
const simple = new Money("100", "USD");
// Custom configuration when needed
const custom = new Money("100", "USD", {
decimals: 4,
displayDecimals: 2,
roundingMode: ROUNDING_MODE.ROUND_DOWN,
});
-
Comprehensive Testing is Crucial: Our test suite covers:
- Edge cases in arithmetic operations
- Rounding behavior across different modes
- Internationalization scenarios
- Type safety validations
- Performance benchmarks
Conclusion
Building a robust money handling library taught us valuable lessons about type safety, precision, and the importance of real-world testing. The library is now being used in production systems handling millions in transactions, proving that careful attention to detail in financial software pays off.
Key takeaways:
- Type safety prevents costly mistakes
- Immutability simplifies reasoning about code
- Comprehensive testing is non-negotiable
- Sensible defaults with flexibility for edge cases
The library is MIT licensed and maintained by Smiling Sloth. We welcome contributions and feedback from the community!
Featured ones: