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.09999999999999987type Cents = bigint & { readonly __brand: 'Cents' };
function cents(n: number | bigint): Cents {
return BigInt(n) as Cents;
}
const price = cents(1999); // $19.99Currencies 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