TypeScript branded types turned compile-time into our most reliable financial auditor here is the pattern, the code, and the lessons from deploying it across a real production PWA.
Estimated reading time: 9 minutes
The Penny That Should Not Exist
Open your browser console right now and type 0.1 + 0.2. Go ahead, I will wait.
You got 0.30000000000000004. Not 0.3. Every developer discovers this at some point and shrugs it off. But when you are building software that handles someone's rent payment, their emergency fund, their debt payoff plan that invisible four-quadrillionth of a cent compounds. It hides in running totals. It lurks in tax calculations. It turns a perfectly balanced budget into one that is off by a penny, and the user stares at the screen wondering if they can trust anything your app is telling them.
I hit this wall while building Talliofi, a local-first financial planning PWA built with React 19 and TypeScript. The app handles income, expenses, budgets, goals, net worth tracking, and multi-currency conversion. Money touches every feature. And every feature was a potential floating-point landmine.
The fix was not a library. It was not a dependency. It was twelve lines of TypeScript that now protect 928 occurrences of monetary values across 97 files in the codebase.
Why Floating-Point Arithmetic Hates Your Money
This is not a new problem. IEEE 754 double-precision floating-point numbers cannot represent most decimal fractions exactly. The number 0.1 in binary is a repeating fraction, like trying to write one-third in decimal. You get close, never exact.
For most applications, "close" is fine. For money, "close" is a lawsuit.
The classic fix is to work in the smallest unit of currency. Instead of storing $19.99 as 19.99, you store 1999, an integer representing cents. Integer arithmetic in JavaScript is exact up to Number.MAX_SAFE_INTEGER (about 9 quadrillion cents, or $90 trillion). That is plenty of headroom for a personal finance app.
But here is the thing nobody tells you: converting to integer cents solves the math problem, not the human problem.
A developer on your team writes a function that accepts a number. Is that number dollars or cents? Is it pre-tax or post-tax income? The type system says number, which tells you nothing. Someone passes in 19.99 where 1999 was expected, and you have a bug that is off by a factor of 100 — far worse than a floating-point rounding error.
I needed the compiler itself to refuse bad money.
The Naive Fix (And Why It Still Fails)
The first attempt is always the same. You create a convention: "All monetary values end in Cents." You name your variables amountCents, incomeCents, balanceCents. You write it in the README. You mention it in code review.
Then someone writes:
const taxRate = 0.22;
const grossIncome: number = 500000; // cents
const tax: number = grossIncome * taxRate; // 110000 -- correct, but is it Cents?
const netIncome: number = grossIncome - tax; // works fine... this time
Everything compiles. Everything runs. The bug arrives three weeks later when a different developer passes netIncome into a function that expects dollars instead of cents. Or when tax gets rounded to a float and passed into another calculation that assumes integer cents.
Naming conventions are documentation. Documentation lies. Types do not.
Enter TypeScript Branded Types
A branded type is a TypeScript pattern that creates a distinct type from a primitive without any runtime overhead. The entire implementation in Talliofi looks like this:
// The branded type: a number that has been validated as integer cents
export type Cents = number & { readonly __brand: 'Cents' };
// The only way to create a Cents value -- our validation gate
export function cents(value: number): Cents {
if (!Number.isSafeInteger(value)) {
throw new Error(`Invalid cents value: ${value}`);
}
return value as Cents;
}
That & { readonly __brand: 'Cents' } intersection is the key. At runtime, it does not exist. There is no extra property on the number. JavaScript has no idea it is there. But TypeScript treats Cents as a distinct type from number. You cannot pass a raw number where Cents is expected without an explicit cast or going through the cents() constructor.
The constructor is your validation gate. Every monetary value in the system must pass through it. If you try to create Cents from 19.99 (a float), NaN, Infinity, or a number beyond safe integer range it throws at runtime. Two layers of protection: compile-time type checking and runtime validation.
For non-technical readers, think of it like a currency exchange booth at an airport. You cannot just hand someone random paper and call it money. You go through the booth, they validate it, stamp it, and now it is recognized currency. The cents() function is that booth.
Why Even Addition Gets Its Own Function
Once you have a branded type, you cannot just add two Cents values with +. The result of a + b in TypeScript is number, not Cents. Your carefully validated types would leak back to raw numbers after a single operation.
So every arithmetic operation gets a wrapper:
export const addMoney = (a: Cents, b: Cents): Cents => cents(a + b);
export const subtractMoney = (a: Cents, b: Cents): Cents => cents(a - b);
// Multiplication and percentage introduce fractions -- Math.round keeps us in integers
export const multiplyMoney = (amount: Cents, factor: number): Cents =>
cents(Math.round(amount * factor));
export const percentOf = (amount: Cents, percent: number): Cents =>
cents(Math.round(amount * (percent / 100)));
// Summing an array -- starts from validated zero, accumulates through addMoney
export const sumMoney = (amounts: readonly Cents[]): Cents =>
amounts.reduce((sum, amt) => addMoney(sum, amt), cents(0));
Notice something subtle: multiplyMoney and percentOf accept a plain number as the second argument, not Cents. That is intentional. A tax rate is not money. A multiplication factor is not money. The type signatures enforce this distinction. You cannot accidentally pass a Cents value where a percentage belongs.
Here is the gotcha that surprises most developers: addMoney re-validates through cents() on every operation. That means overflow protection is automatic. If two enormous Cents values added together exceed Number.MAX_SAFE_INTEGER, the cents() constructor throws immediately rather than silently producing a corrupt value. Our test suite verifies this explicitly:
it('addMoney throws when result exceeds MAX_SAFE_INTEGER', () => {
const large = cents(Number.MAX_SAFE_INTEGER - 1);
expect(() => addMoney(large, cents(2))).toThrow('Invalid cents value');
});
Division by zero? Also caught. Math.round(100 / 0) produces Infinity, and cents(Infinity) throws. No special handling needed — the validation gate catches everything.
The Performance Play: Caching Intl.NumberFormat
Formatting money for display happens constantly in a financial app. Every list item, every chart tooltip, every summary card. Creating a new Intl.NumberFormat object on every render is expensive — these constructors parse locale data and build internal state.
Talliofi caches formatters by locale and currency:
const formatterCache = new Map<string, Intl.NumberFormat>();
export function formatMoney(
amount: Cents,
options: { locale?: string; currency: CurrencyCode },
): string {
const { locale = 'en-US', currency } = options;
const key = `${locale}:${currency}`; // e.g., "en-US:USD" or "de-DE:EUR"
let formatter = formatterCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, { style: 'currency', currency });
formatterCache.set(key, formatter);
}
return formatter.format(centsToDollars(amount));
}
The function signature enforces correctness: it accepts Cents, not number. You cannot accidentally format raw dollars or an unconverted value. The conversion to display-friendly dollars happens once, at the very last moment, inside this function. Everywhere else in the system, money stays in its branded integer form.
With six supported currencies and their associated locales, the cache holds at most a handful of entries. The memory cost is negligible. The performance gain on rendering a list of 200 expenses is noticeable.
Being Honest About Uncertainty: The ConversionResult Pattern
Multi-currency support introduced a design question I had not anticipated. When you convert USD to EUR and the exchange rate is unavailable (the user is offline, the rate API is down, the currency pair is not configured), what do you return?
Returning the original amount silently is dangerous the user would see "$500" labeled as "EUR 500" and never know the conversion failed. Throwing an error is disruptive for a display-only calculation. Returning null forces every consumer to handle the missing case.
Talliofi uses a tagged result:
export interface ConversionResult {
readonly amount: Cents;
/** Whether the amount was actually converted using a real exchange rate */
readonly converted: boolean;
}
Callers get their Cents value regardless, but they also get a boolean that says "I actually converted this" versus "I just gave you back the original because I did not have a rate." The UI can then decide how to present this showing a warning icon, displaying "approximate" text, or fetching fresh rates.
This pattern is worth stealing for any domain where a function might produce a plausible-looking result from incomplete data. Do not hide uncertainty. Type it.
What 928 Occurrences Across 97 Files Looks Like
The Cents type is not a novelty confined to a utility file. It flows through the entire domain:
// Plans track income in Cents
export interface Plan {
readonly grossIncomeCents: Cents;
// ...
}
// Every expense uses Cents -- no raw numbers allowed
export interface ExpenseItem {
readonly amountCents: Cents;
// ...
}
// Split transactions carry Cents per split
export interface ExpenseSplit {
readonly amountCents: Cents;
// ...
}
// Goals, assets, liabilities, snapshots -- all branded
export interface Goal {
readonly targetAmountCents: Cents;
readonly currentAmountCents: Cents;
}
From income calculations to tax estimates to budget summaries to multi-month trend charts, every monetary value in Talliofi is Cents. The branded type creates a closed system. Money enters through cents() or dollarsToCents(), stays branded through all calculations, and only converts back to a display-friendly number at the very edge — inside formatMoney(). A new developer joining the project cannot accidentally create monetary bugs without the compiler flagging it.
What Almost Broke Me
The hardest part was not the type system. It was the migration.
When I introduced the branded type, every function that touched money needed to change. Not just the types, the call sites. Every place that did income - expenses had to become subtractMoney(income, expenses). Every array.reduce((sum, x) => sum + x.amount, 0) had to become sumMoney(items.map(i => i.amountCents)).
The compiler caught every one. That was the point. But "the compiler tells you where to fix things" is different from "the compiler fixes things for you." It was a methodical, file-by-file grind.
The lesson: introduce branded types early. The cost of adopting them grows linearly with your codebase. In a fresh project, it is nearly free. In a project with 97 files touching money, it is a weekend.
What I Learned (If I Built This Again)
1. Brand early, brand aggressively. I would introduce Cents in the very first commit, before any business logic exists. The migration cost is real and grows with time.
2. The constructor is the entire security model. The cents() function is one of the most important functions in the codebase. It is small, it is simple, and it is the single chokepoint for monetary validation. Guard it. Test it. Never bypass it.
3. This pattern generalizes far beyond money. Percentages (0–100 vs 0–1), timestamps (milliseconds vs seconds), distances (meters vs feet), IDs (user IDs vs order IDs), any domain where raw numbers carry implicit meaning is a candidate for branding.
4. Readonly interfaces compound the safety. Every domain type in Talliofi uses readonly properties. Combined with branded types, this means monetary values cannot be created incorrectly (branded constructor) or mutated incorrectly (readonly fields). Two fences, one enclosure.
5. The converted: boolean pattern pays for itself. Anywhere your code produces a "best effort" result, tag it with a confidence signal. Do not make downstream consumers guess whether the value is real or approximate.
Start Here
If you take one thing from this article, take the twelve lines. Copy the Cents type and the cents() constructor into your next TypeScript project that touches money. Write the arithmetic wrappers. Make your compiler do the auditing.
The pattern is small. The protection is not.
The best type systems do not just describe your code. They make entire categories of mistakes impossible to write.
Top comments (0)