Forem

Smiling Sloth
Smiling Sloth

Posted on • Edited on

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. An e-commerce platform losing thousands of dollars due to rounding errors in bulk discounts
  2. A payment processor having mismatched totals because of currency conversion rounding
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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"
// ]
Enter fullscreen mode Exit fullscreen mode

Technical Decisions and Lessons Learned

  1. 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");
Enter fullscreen mode Exit fullscreen mode
  1. 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"
Enter fullscreen mode Exit fullscreen mode
  1. 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,
   });
Enter fullscreen mode Exit fullscreen mode
  1. 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!

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up