Building a Type-Safe Money Handling Library in TypeScript: Lessons from the Financial Technology Trenches
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
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: A Ground-Up Redesign
1. Decimal Precision Done Right
We chose decimal.js
as our foundation after evaluating several options:
import { Money } from "@thesmilingsloth/money-utils";
// Precise calculations, even with complex operations
const principal = new Money("10000.00", "USD");
const interestRate = 0.0525; // 5.25% APR
const years = 5;
const compound = principal.multiply(
Math.pow(1 + interestRate, years)
).round(2);
console.log(compound.toString()); // "$12,900.85"
2. Currency as a First-Class Citizen
Unlike many libraries that treat currency as an afterthought, we made it central to our design:
import { Currency } from "@thesmilingsloth/money-utils";
// Built-in support for major currencies
const usd = Currency.USD;
const eur = Currency.EUR;
const btc = Currency.BTC;
// Custom currency support for tokens or new cryptocurrencies
Currency.register({
name: "Corporate Loyalty Points",
code: "POINTS",
symbol: "⭐",
symbolPosition: "prefix",
decimals: 0,
minorUnits: "1",
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. Smart Allocation with Real-World Examples
Consider splitting a restaurant bill with tax and tip:
const bill = new Money("100.00", "USD");
const tax = bill.multiply(0.08); // 8% tax
const tip = bill.add(tax).multiply(0.20); // 20% tip on subtotal + tax
const total = bill.add(tax).add(tip);
// Split among 3 friends
const shares = total.allocate([1, 1, 1]);
console.log(shares.map(s => s.toString()));
// ["$42.67", "42.67", "42.66"]
// Note: Handles the penny remainder automatically
4. Internationalization Beyond Basic Formatting
Real international applications need more than just symbol placement:
const amount = new Money("1234567.89", "JPY");
// Locale-aware formatting
console.log(amount.toLocaleString("ja-JP")); // "¥1,234,568"
console.log(amount.toLocaleString("en-US")); // "¥1,234,568"
console.log(amount.toLocaleString("de-DE")); // "1.234.568 ¥"
// Custom formatting options
console.log(amount.toLocaleString("en-US", {
style: "currency",
currencyDisplay: "name"
})); // "1,234,568 Japanese yen"
Advanced Features and Edge Cases
1. Handling Cryptocurrency Precision
Cryptocurrencies often require higher precision than traditional currencies:
// Bitcoin with 8 decimal places
const satoshi = new Money("0.00000001", "BTC");
const bitcoin = new Money("1", "BTC");
// Ethereum with 18 decimal places
Currency.register({
name: "Ethereum",
code: "ETH",
symbol: "Ξ",
decimals: 18,
minorUnits: "1000000000000000000",
isCrypto: true
});
const wei = new Money("1", "ETH");
console.log(wei.toString()); // "Ξ0.000000000000000001"
2. Complex Allocation Scenarios
Consider a investment profit distribution with different stakeholder percentages:
const profit = new Money("1000000.00", "USD");
const shares = profit.allocate([
15, // 15% - Founder
25, // 25% - Investors
30, // 30% - Operations
20, // 20% - Development
10 // 10% - Reserve
]);
// Results maintain exact proportions while handling remainders
console.log(shares.map(s => s.toString()));
// [
// "$150,000.00",
// "$250,000.00",
// "$300,000.00",
// "$200,000.00",
// "$100,000.00"
// ]
Performance Optimizations
We've implemented several optimizations:
- Memoized Formatting: Caching formatted results for frequently used values
- Lazy Evaluation: Deferring expensive operations until needed
- Efficient Memory Usage: Structural sharing in immutable operations where possible
// Benchmark results on a MacBook Pro M1:
// 1 million operations
console.time('money-utils');
for (let i = 0; i < 1000000; i++) {
const a = new Money("99.99", "USD");
const b = new Money("49.99", "USD");
const c = a.add(b).multiply(1.08);
}
console.timeEnd('money-utils');
// money-utils: ~450ms
Real-World Integration Examples
1. E-commerce Cart Calculations
class ShoppingCart {
private items: Array<{product: Product, quantity: number}> = [];
private taxRate = 0.08;
addItem(product: Product, quantity: number) {
this.items.push({ product, quantity });
}
calculateTotal(): Money {
const subtotal = this.items.reduce((sum, item) => {
const itemTotal = item.product.price.multiply(item.quantity);
return sum.add(itemTotal);
}, Money.zero("USD"));
const tax = subtotal.multiply(this.taxRate);
return subtotal.add(tax);
}
}
2. Subscription Billing
class SubscriptionBilling {
calculateProration(
monthlyRate: Money,
daysInBillingCycle: number,
daysUsed: number
): Money {
return monthlyRate
.multiply(daysUsed)
.divide(daysInBillingCycle)
.round(2);
}
generateInvoice(subscription: Subscription): Money {
const base = subscription.baseRate;
const usage = this.calculateUsageCharges(subscription);
const discounts = this.applyDiscounts(subscription);
return base
.add(usage)
.subtract(discounts)
.round(2);
}
}
Lessons Learned
- Type Safety is Non-Negotiable: TypeScript's type system caught countless potential bugs during development.
- Immutability Simplifies Reasoning: Making Money instances immutable eliminated an entire class of bugs.
- Testing Edge Cases is Crucial: Our test suite includes over 1000 cases covering various scenarios.
- Performance vs. Precision: We chose precision over performance but optimized where possible.
What's Next?
We're working on exciting features:
- Currency Conversion: Built-in support for currency conversion
Conclusion
Building @thesmilingsloth/money-utils
has been a journey through the complexities of financial calculations in software. The library embodies lessons learned from real-world applications and provides a robust foundation for handling monetary calculations in TypeScript applications.
Try it out and let us know what you think! We welcome contributions and feedback on GitHub.
This library is MIT licensed and maintained by Smiling Sloth. Star us on GitHub if you find it useful!
Top comments (0)