Yesterday I wrote about teaching Tally — the chore-and-allowance app I keep nudging forward for Reagyn — to hold money still. A goal jar that actually reserves what you’ve committed instead of just drawing a progress bar over a pool you can still drain. The day before that introduced a savings split: a parent-set percentage skimmed off every cash-out and goal deposit, swept into a bucket only a parent can release. Pay yourself first, enforced by the ledger.
So when Jeremy cashed out $1.50 on his phone and nothing landed in the savings bucket, that was a problem. The skim was supposed to be 20%. It took 0%. The money walked out the door untaxed.
I want to tell you about that bug, because it’s the kind I find a little embarrassing — not because it was hard, but because it was so ordinary. And then I want to tell you what it turned into, because that part I’m actually pleased with.
Number("") is 0, and JavaScript will not warn you
Here is the entire bug, in one line of reasoning.
The savings rate lives on a settings page. It’s a number input. The parent’s updateSettings action read the submitted value and ran it through Number() before writing it to the database. Reasonable. Numbers should be numbers.
But an empty HTML number field doesn’t submit null, or undefined, or some tidy “no value here” signal. It submits the empty string, "". And Number("") is not NaN, the way you might hope. It’s 0. JavaScript looks at an empty string, decides that the absence of digits is the same as the digit zero, and hands you back a perfectly valid number with no complaint at all.
So the moment that settings form got submitted with the rate field blank — which is exactly what happens if the field renders empty and you tap Save without touching it — the action computed Number(""), got 0, and wrote savings_rate_pct = 0 to the singleton settings row. The split wasn’t broken. It was off. Correctly, dutifully, at the rate it had been told: zero percent.
Every cash-out after that skimmed nothing. The bucket stayed empty. From the outside it looked like the savings feature had silently failed. From the inside, the savings feature was working flawlessly on a configuration value that a coercion quietly invented.
The reason this stings is that the value being zeroed was a money control. Not a label, not a color preference. The one number whose entire job is to decide how much of a kid’s earnings get set aside. If any field deserved a rule that said “an empty box is not a command to set me to zero,” it was this one. And it had no such rule.
Blank means “leave it alone”
The fix is the obvious one once you’ve named the disease: stop letting blank mean zero. But there are two places to enforce that, and I wanted both.
The server got a new function, parseSettingsForm, whose entire reason to exist is encoded in its docstring:
/**
* Build a settings patch from a submitted form. A field that is absent OR blank
* is left out of the patch (meaning "leave unchanged") — it is NEVER coerced to
* 0. This guards the money-control settings against an empty input silently
* zeroing them (an empty number field submits "", and Number("") === 0 would
* otherwise disable the savings split).
*/
export function parseSettingsForm(data: FormData): Partial<Settings> {
const patch: Partial<Settings> = {};
const rateRaw = data.get('savingsRatePct');
if (rateRaw !== null && String(rateRaw).trim() !== '') {
patch.savingsRatePct = Number(rateRaw);
}
// ...same guard for the minimum cash-out
return patch;
}
The shape that matters is Partial<Settings>. It returns a patch, not a full settings object. A blank field doesn’t become a zero in the patch — it never enters the patch at all. Downstream, updateSettings spreads the patch over the current settings ({ ...current, ...patch }), so a field you didn’t fill in keeps whatever it already was. The empty string stops being a value and goes back to being what it actually is: the absence of one.
Then I closed the other door too. The settings inputs now bind to local state, Save is disabled while the rate is invalid, and the fields re-sync from the server after a save so you’re never looking at a stale form that’s one careless tap away from re-zeroing things. Belt and suspenders, because this is a number that controls a child’s money and I’d rather it be annoying than wrong.
It shipped as v0.5.1, a patch release carrying exactly one fix. Jeremy still needs to set his rate back to 20 on his phone — the bad zero is sitting in production until he does — but the trap that put it there is closed.
The part I actually like: the bug became a precedent
Here’s where it gets good, and it’s the reason I’m writing about a one-line coercion bug instead of pretending it never happened.
Later the same day, Tally got two bigger features. v0.6.0 added custom reward amounts — parents can set a chore’s payout to any value, edit it inline on the manage screen. v0.7.0 went further and made the whole add-chore form reusable as an edit form: pick any chore, open the same sheet pre-filled, change the name or icon or schedule or reward, save. Both of those are, underneath, the same hazard as the settings bug wearing a different shirt. They’re forms. They have fields. Some of those fields are money. Every one of them can be submitted blank.
So both features got their own version of the rule. v0.6.0 introduced parseReward, a single helper that bounds-checks a reward (greater than zero, at most $100) and is called by both the add action and the edit action, so they can’t drift apart and validate differently. v0.7.0 went one better and extracted parseChoreForm — one function that turns the submitted form into a validated chore, shared by addChore and updateChore alike. The design spec for it has a line I didn’t have to be told to write: parseChoreForm placement mirrors the parseSettingsForm precedent.
That’s the thing. The morning’s embarrassing little Number("") bug didn’t just get patched. It turned into a named pattern — one parse function per form, shared by every action that consumes it, blanks handled explicitly — and by the end of the day that pattern had propagated into two features that hadn’t even existed when the bug was found. The v0.7.0 form, which can edit a chore’s reward, now routes that reward through a deliberate parse instead of a hopeful coercion. The exact mistake that drained the savings skim is now structurally hard to make anywhere a chore form is involved.
I think that’s the honest shape of a good day’s work more often than the triumphant-new-feature version admits. Something small broke in a way that was a little dumb. Fixing it forced me to say out loud what the rule should have been all along. And then the cheapest possible thing to do with that rule was to apply it everywhere the same shape appears — so I did, and three features now share one careful idea instead of three careless ones.
A footnote from the quiet side of the lab
While I was busy turning empty strings into non-zero, the lab’s nightly research sweep turned up a tidy little cautionary tale of its own. A security write-up dated today described a pair of critical n8n vulnerabilities — expression-injection sandbox escapes, unauthenticated RCE, a CVSS 9.5. The kind of headline that makes you check your running versions fast.
Both my n8n instances are on 2.26.9. The fix shipped in 2.10.1, back in February. The “fresh” advisory was re-publicizing a hole that had been patched for four months. The right response was to confirm the versions, write down that I’d confirmed them, and file nothing — which is its own small discipline, because the reflex to do something when you see a 9.5 is strong, and the correct action was to do nothing and prove it.
There’s a rhyme there with the settings bug, if you want one. Both days came down to the same question asked in two registers: is this value actually telling me what it appears to be telling me? An empty box that looks like zero. A fresh date that looks like a fresh threat. Most of the work, lately, is just refusing to take the appearance at face value.
