· 6 min read
Null, never zero
A missing figure is obvious and a zero isn't, so bad input has to be refused the same way by every implementation that reads it.
Why a missing rate shows as a gap
If a payslip doesn't have an exchange rate yet, the right way to show it is a gap, like a dash, a blank cell or a line the chart doesn't draw, and only a gap is safe to display. Anybody looking at it knows something's missing and goes looking for why. A month of salary shown as $0.00 says nothing of the kind. It's a figure, it sits among other figures, it sums and it charts, and nothing in the system will ever flag it.
So when the layer that converts money has no rate, it returns null, never zero. A payslip whose rate hasn't arrived isn't a month I earned nothing, and every caller has to be able to tell those apart.
The rest of this note is about how many places that rule has to be defended. Deciding what "no answer" looks like is the first thing I'd do next time, before writing the code that produces answers, and then I'd check every layer can show it.
The same bad input, two different answers
The conversion rule exists twice, in TypeScript on the desktop and Kotlin on the phone. Both scale the rate up to an integer, multiply in exact integer arithmetic, and round half up. It's the same algorithm, the same constants and the same expected output on every case I'd written down.
Then I asked what each one does with input that isn't a rate at all.
// Math.round(Double.NaN) is 0L on the JVM. Silently. No exception.
val rateScaled = Math.round(rate * RATE_SCALE) // 0
// ... and the whole conversion returns 0LA broken rate on the phone produced a payslip worth $0.00, which is what the layer is meant to prevent. The desktop threw on the same input, but only by accident. BigInt(NaN) is a TypeError, so the error came out of a coercion and not a rule, and when that path was handed a negative rate, it produced a plausible negative figure with no complaint at all.
The date maths had the opposite problem, in the bit that picks which rate a day is allowed to use.
const gap = daysBetween(row.as_of, asOf) // NaN for a malformed date
if (gap > MAX_BACKFILL_DAYS) continue // NaN > 4 is false — so we keep itA malformed as_of makes daysBetween return NaN, and every comparison against NaN is false, so the guard waved the row through. Kotlin's LocalDate.parse throws on the same row. So on top of disagreeing about the answer, the two devices disagreed about whether there even was one.
Neither default is wrong for its language. Math.round(NaN) == 0L is reasonable for a JVM primitive, and IEEE's rule that NaN compares false against everything is the correct definition. They're wrong here because two ports inherited two different defaults for a case neither author had written a rule for. Where the code says nothing, the language fills the gap, and TypeScript and Kotlin fill it differently. The bug has very little to do with NaN, which just happened to be the case that showed it.
Both sides now state the rule out loud. A rate has to be a positive finite number, and a row whose as_of isn't a date is skipped rather than accepted.
Number.isFinite(0) is true
That closed the arithmetic. The hole that produced a real zero was somewhere else.
The parser that reads published rates validated them with Number.isFinite(rate), which is the check you reach for without thinking, and it's true for 0. A zero rate passed validation, got stored as an ordinary row, and the conversion did what it was told, so a month of salary showed up as $0.
Nothing was broken as such. The parser, the store and the conversion all did their jobs, but the validator was asking "is this a number?" when it should have been asking "is this a rate?"
No currency has ever been worth nothing, or less. So the parser rejects rate <= 0, and the database has a CHECK constraint as a backup, so the rule holds even if some other code path skips the parser.
One bad row shouldn't blank the page
The last piece is the one I got wrong on the first pass, and I think it's the most useful part of this note.
Once the conversion throws on a bad rate, there's a new way for things to fail. Rates can still reach the local database by a path that bypasses the parser, and the local store has no constraint. One bad row, a single stored zero out of hundreds, threw out of the conversion and rejected the whole promise that returns every payslip with its converted value, so the reports page would have rendered nothing at all.
Throwing is right when the caller has a bug. A caller passing NaN has one and should hear about it loudly. A malformed stored row is different, because the caller hasn't done anything wrong and there's just one fact that isn't available.
So a bad stored rate is treated as no rate, which is a state the layer already knows how to express. One payslip shows a gap, the other twenty-six show their figures, and the gap is visible enough that I go and look at it.
Pinning the two constants
Two constants carry this whole arrangement. One is the maximum number of days a date can reach back for a rate, and the other is the integer scale the rate is multiplied up to.
export const MAX_BACKFILL_DAYS = 4
export const RATE_SCALE = 1e8Both were written twice, once per language, and no test on either side asserted either value. MAX_BACKFILL_DAYS could have gone from 4 to 30 and both suites would have stayed green, on two devices that have to agree about what a month's salary was worth.
They're stated once now, in a shared hand-written corpus both suites read at test time, and they're pinned from behaviour as well as by value. The cases include the boundary pair, so four days back is accepted and five is rejected.
The rounding went the same way. The magnitude cases (100 @ 1.005 → 101, 5202500 @ 1.005 → 5228513) only existed on the desktop, so the hardest part of the Kotlin port was regression-tested in one language and trusted in the other. The second case matters because 5202500.0 * 1.005 in double arithmetic is 5228512.499999999, a hair under the true tie point, so a naive multiply and round gets it wrong in the direction nobody checks.