The number that disagreed with itself
I opened our admin dashboard and the same figure was on screen twice — and the two copies disagreed by a factor of a hundred.
One card said ₦1,040 of revenue was at risk. The chart two panels below said ₦104k was pending. Same constant in the code, 104000, rendered two ways. Nobody had changed anything; both were live.
When a number disagrees with itself on the same screen, formatting isn’t broken. Something about the unit is.
This is for anyone who has ever formatted a money value and trusted it. You should know what a number is in JavaScript, and roughly how Intl.NumberFormat works.
Money has units, not just a currency
A currency tells you the kind of money: naira, dollars, pounds. A unit tells you the size — and that’s a separate question.
₦1 is one hundred kobo, the way a metre is one hundred centimetres. Payment providers price in kobo, APIs store kobo, and the UI is the only place a value ever becomes “₦”. So the question is never “what currency is this?” — it’s “is this number naira, or is it kobo?”
Here’s the trap: a JavaScript number can’t carry that answer. There is no “kobo” in 104000. The type only says “a number”:
export interface OrderListItem {
// ...
total_amount: number;
// ...
}That bare number is the whole problem. Whatever the unit is, it has to be said out loud — because the moment two code paths assume different units, you get this post.
Two formatters, two assumptions
We had two formatters in src/lib/money.ts. Both produce Nigerian naira. One assumes the input is already naira; the other assumes it’s kobo and divides by 100:
export function formatMoney(value: number): string {
// formats the value AS-IS — assumes naira
return new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN" }).format(value);
}
export function formatCurrency(koboValue: number): string {
// assumes kobo — divides by 100 first
return formatMoney(koboValue / 100);
}Feed the same number to both and you get answers a hundred times apart:
formatMoney(104000) // "₦104,000"
formatCurrency(104000) // "₦1,040"Here’s the part everyone gets wrong: neither function is misformatting. Each formats its own number correctly — they just disagree about what the number means. A units bug always looks like a formatting bug, and it is never one.
The demo below is the collision made visible. Pick a raw number, then pick the unit the API actually sent, and watch each formatter’s assumption line up — or not.
The dots tell you which assumption matches the truth. Same raw number, same currency — four different prices, and only one is real.
The comment that was doing the type system’s job
The worst part was how honest we were about not knowing. In the orders table, the money column shipped with this comment:
// Assuming 100000 means ₦1,000.00 (Kobo representation) or scale as required
formatCurrency(amount)Read that again: “or scale as required.” The developer who wrote the cell didn’t know the unit either, so they wrote the number down and asked the next person to guess. When a unit is a comment, every call site becomes a guess — and each guess can be wrong differently.
The cleanest version of the mess was one line above it. Our own summary metric named the unit in the variable and converted correctly:
totalRevenueKobo += order.total_amount || 0; // named it — "Kobo"
// ...
revenue: totalRevenueKobo / 100, // converted itThat’s the whole fix, applied to one line. The tragedy is that this correct pattern lived right next to the guessing.
The same screen, twice
Where it actually broke was the dashboard. The revenue chart had two renderers for the same bars:
- the Y-axis tick formatter
compactCurrency, which assumes naira — so it showed₦104k - the tooltip formatter
formatCurrency, which assumes kobo — so it showed₦1,040
Same bar, two labels. You could hover over the ₦104k bar and be told it was worth ₦1,040. That’s the demo I keep coming back to:
Hover a bar. Compare what it says to its axis label.
Flip the toggle and both read the number the same way. Nothing about the numbers changed — we just stopped letting each formatter guess.
Formatting can’t fix a units bug
This is the counterintuitive part, so let me say it loudly: you cannot fix this in the formatter.
A formatter’s job is cosmetic — commas, decimals, the ₦ sign. The moment you write value / 100 inside a function called “format”, you’ve smuggled a unit conversion into a display concern. Conversion and display are two different problems that should never share a function.
Here’s proof that even the “correct” path was fragile. Our NGN config declared decimals: 0:
NGN: { code: "NGN", symbol: "₦", locale: "en-NG", decimals: 0 },So formatCurrency(123456) — one hundred and twenty-three thousand, four hundred and fifty-six kobo — became formatMoney(1234.56), and the zero-decimal NGN formatter rounded it to ₦1,235. An order worth ₦1,234.56 rendered as ₦1,235. The config wasn’t wrong and the formatter wasn’t wrong. The unit was the only thing ever wrong, and nothing in the code owned it.
Make the unit a first-class citizen
The same team’s mobile app had already solved it. In constants/currency.ts the unit is part of the API, not an assumption:
export type CurrencyUnit = 'kobo' | 'naira';
export function koboToNaira(amount: number | string | bigint): number {
const kobo = parseMoneyNumber(amount);
return Number.isFinite(kobo) ? kobo / 100 : 0;
}
export function formatCurrency(
amount: number | string | bigint,
decimals: number = 2,
unit: CurrencyUnit = 'kobo', // the unit is right there
showDecimals: ShowDecimalsMode = 'auto',
): string {
const numAmount = toNairaAmount(amount, unit); // convert, explicitly
// ...then format, and only format
}Three moves made the collision impossible:
- The unit is a type.
CurrencyUnitexists andformatCurrencytakes it as an argument. You can’t format money without saying what you’re holding. - Conversion is separate from formatting.
koboToNairaandnairaToKoboeach do one job. The formatter converts to naira once, at the display boundary. - Money stays integers. Kobo end to end, no floats. The file even refuses to trust big money as a JS
number:
// Ask the API to return BIGINT money values as strings to avoid precision loss.
if (Number.isInteger(amount) && Math.abs(amount) > Number.MAX_SAFE_INTEGER) {
console.warn('[currency] Unsafe integer money value received as number.', amount);
}Floating point can’t represent kobo cleanly, and a JS number can’t represent most real money totals exactly. Store kobo as integers, convert once, format last.
The question to ask every API
There’s a one-line rule that would have prevented this entire post:
Every money value is stored in its smallest unit, and the unit is written in the type, not a comment.
So ask your backend this today: in what unit does total_amount arrive? If the answer isn’t in the schema, it’s a comment somewhere — and a comment is just a guess waiting to disagree with itself.
Try the demo again. The raw number never changes. The only thing that moves is who gets to guess the unit.