Writing a ledger in TypeScript
A ledger is the boring, append-only, double-entry record of who owes what to whom. Every payment app, every wallet, every "credits" system you've ever used has one underneath, and the first time you try to write one in TypeScript you're gonna get bit by like four things in a row that all feel unfair.
I got bit by all of them. This is me writing down what I'd tell past-me before they shipped that first version.
Money is not a number
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.09999999999999987This is fine for physics, terrible for money. The fix is to store everything as integer minor units (cents, satoshis, whatever) using bigint. Not number. bigint. Because number can only safely represent integers up to 2^53, which sounds like a lot until you're a bank handling billions in fractions of a yen.
type Cents = bigint & { readonly __brand: 'Cents' };
function cents(n: number | bigint): Cents {
return BigInt(n) as Cents;
}
const price = cents(1999); // $19.99The branded type is the important bit. Without it, nothing stops you from adding a Cents to a regular bigint (like a user ID), and the compiler shrugs because they're both bigint under the hood. With the brand, you can only add Cents to Cents, which is what you actually want.
Currencies are a type, not a string
Same story. "USD" and "usd" and "US$" are all strings, all distinct, and your reconciliation job is going to be a horror movie.
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 };
}Catches the "we accidentally added EUR to USD" bug before it gets anywhere near a balance you care about.
Entries are immutable, transactions are atomic
Every change to the ledger is an entry, every entry is append-only, and every transaction is a group of entries that must sum to zero. That last rule is the entire reason double-entry exists, so it's the rule the code should enforce hardest.
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]) };
}That sum !== 0n check is the whole game. If the transaction doesn't balance, the function throws and the bad transaction just,, doesn't exist.
Object.freeze is there because readonly in TS is compile-time only. I learned this when I .push()'d into a readonly array at runtime and nothing complained.
The ledger itself
The ledger is just an append-only list of transactions plus a way to ask "what's the balance of this account right now." That's it. If you got something wrong, you post a reversing transaction. The history is the truth.
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;
}
}The seenIds check is idempotency. If the same transaction gets posted twice (retried webhook, double-clicked button, whatever), the second call returns the original instead of double-posting. The transaction ID is the idempotency key, which felt like cheating the first time I did it but it's just,, correct.
balance walks the whole history every time. Fine until it isn't. The escape hatch is a snapshot (checkpoint balance + everything after it) but don't build that until the walk actually hurts. I built it on day one once, didn't need it for months, and the snapshot logic was where the bugs lived.
Serializing money is its own minefield
JSON.stringify does not know what to do with bigint. It throws. So if you naively return a Money over the wire, your API blows up.
Pick one:
- Serialize as
{ amount: "1999", currency: "USD" }, string for the amount. Cleanest, least surprising on the receiving end. - Serialize as
{ amount: 1999, currency: "USD" }, but only if you can guarantee no value will exceedNumber.MAX_SAFE_INTEGER(you can't, don't). - Use a custom replacer in
JSON.stringify. Works but the rule lives in one file forever.
Whichever you pick, write it down somewhere obvious, because the second someone hits a TypeError from JSON.stringify(tx) they're gonna "fix" it by casting to Number and now there's a silent precision bug nobody can find.
Validate at the boundary, again
If the transaction is coming over the wire, parse it before you trust it. Zod schema, z.coerce.bigint() for amounts, enum for the currency. The internal types are tight because something at the door made them tight.
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.
TLDR
bigintfor amounts. Nevernumber. Floats are not your friend here.- Branded types for
Cents,Currency,AccountId. Anything where mixing two of them up costs money. - One
buildTransactionfunction is the only place entries become a transaction. It checks balance, checks currency, freezes the array. If something tries to skip it, that's the bug. - The ledger is append-only. Want to undo? Post the opposite. The history is the truth.
- Pick a serialization rule for
bigintonce and keep it in one file. Future-you will thank present-you.