Thoughts

Writing a ledger in TypeScript

· 2 min read

First rule, the one that breaks 90% of toy ledgers, is don't store money as number. JavaScript's number is an IEEE 754 double, which means

0.1 + 0.2; // 0.30000000000000004
1.4 - 1.3; // 0.09999999999999987
type Cents = bigint & { readonly __brand: 'Cents' };

function cents(n: number | bigint): Cents {
  return BigInt(n) as Cents;
}

const price = cents(1999); // $19.99

Currencies are a type, not a string

type Currency = 'USD' | 'EUR' | 'JPY' | 'GBP';

type Money = {
  amount: Cents;
  currency: Currency;
};

function money(amount: number | bigint, currency: Currency): Money {
  return { amount: cents(amount), currency };
}

Now adding two Money values requires you to check the currency first, and the type system makes you do it:

function add(a: Money, b: Money): Money {
  if (a.currency !== b.currency) {
    throw new Error(`currency mismatch: ${a.currency} vs ${b.currency}`);
  }
  return { amount: (a.amount + b.amount) as Cents, currency: a.currency };
}

Entries are immutable, transactions are atomic

type Entry = {
  accountId: string;
  amount: Cents;          // positive = debit, negative = credit
  currency: Currency;
};

type Transaction = {
  id: string;
  occurredAt: Date;
  entries: readonly Entry[];
  memo?: string;
};

function buildTransaction(
  entries: Entry[],
  meta: { id: string; occurredAt: Date; memo?: string }
): Transaction {
  const sum = entries.reduce((acc, e) => acc + e.amount, 0n);
  if (sum !== 0n) {
    throw new Error(`transaction does not balance: sum is ${sum}`);
  }
  if (new Set(entries.map(e => e.currency)).size > 1) {
    throw new Error('multi-currency transaction in single-currency tx');
  }
  return { ...meta, entries: Object.freeze([...entries]) };
}
class Ledger {
  private readonly transactions: Transaction[] = [];
  private readonly seenIds = new Set<string>();

  post(tx: Transaction): Transaction {
    if (this.seenIds.has(tx.id)) {
      return this.transactions.find(t => t.id === tx.id)!;
    }
    this.transactions.push(tx);
    this.seenIds.add(tx.id);
    return tx;
  }

  balance(accountId: string, currency: Currency): Cents {
    let total = 0n;
    for (const tx of this.transactions) {
      for (const e of tx.entries) {
        if (e.accountId === accountId && e.currency === currency) {
          total += e.amount;
        }
      }
    }
    return total as Cents;
  }
}

example of validation

const MoneySchema = z.object({
  amount: z.coerce.bigint(),
  currency: z.enum(['USD', 'EUR', 'JPY', 'GBP']),
});

If someone POSTs { amount: "🦀", currency: "FAKE" }, you get a clean validation error at the call site instead of a NaN ten functions deep