The bonus-adjustment amount field stopped accepting negative and decimal input. Typecheck, lint, and tests had all passed.
1. Background — type debt and a bypass switch
When I joined, this codebase had essentially no types and no linting. TypeScript was configured in name only; in practice it was full of any and implicit unknown.
I was the one who introduced lint and typecheck. The problem: turning them on surfaced hundreds of errors at once, and I couldn’t fix them all before shipping. So the production build carried a type-error bypass switch (ignoreBuildErrors, gated behind a CI flag), and I decided to pay the debt down gradually.
2. Approach — how the refactor was split
This was part of a broader lint-and-type cleanup. Instead of taking hundreds of errors head-on, I split the work along two axes, and split each axis again into mechanical first, judgment last. Each step ran as its own PR, with AI assistance.
Lint:
- Harden the ESLint config — add DOM globals, drop rules that conflict with the TS plugin
- Clear 368 unused-variable warnings across 132 files
- Triage
no-explicit-any— convertanytounknown(make implicit any explicit) - Remove eslint’s
ignoreDuringBuildsso lint becomes a blocking gate
Types:
- MUI v7 compatibility — move palette augmentation to a global
.d.ts, fix deep-import paths, delete unused components - Replace the global JSX namespace with
React.JSXfor React 19 - Convert 313
unknowntypes to proper types across 86 files ← where the regression entered - Remove the
ignoreBuildErrorsbypass
The logic was simple: separate lint from types, clear the mechanical stuff first (config, unused vars, import paths), and leave the judgment calls for last. Each step drove the type-error count down (e.g. 428 → 364), aiming to earn the right to remove the bypass at the end.
There’s an easy-to-miss causal chain here. Converting any to unknown was a safe, mechanical move. But unknown is useless until you narrow it — so those 313 values then had to be turned into real types, a step that requires judging what each value actually means. And that judgment step is exactly where the regression rode in.
By the numbers it looked flawless. Type errors dropped at every step (428 → … → 0), and 1,000-plus tests stayed green. Inside that green, one field’s behavior quietly changed.
3. What broke
After deploy, a report came in: in the bonus-adjustment dialog, users couldn’t type negative or decimal values into the amount fields (there are six). Typing - erased it on the next keystroke; an in-progress decimal like 5. wouldn’t hold either.
The cause was one line introduced during the type conversion. To make the field match its number type, an onChange that had kept the input as a string was wrapped in Number():
// before
const amt = event.target.value; // string
// after
const amt = Number(event.target.value); // number
Number("-") is NaN, and Number("5.") is 5. So the minus sign and the in-progress decimal point got mangled on every keystroke. The final value looks fine (Number("-0.1") is -0.1) — it’s the intermediate states, before input is finished, that break. (The fix swapped the six inputs to the same approach used by the sibling dialogs, keeping the number type while restoring negative and decimal input.)
4. Why it slipped — a rule written nowhere in the code
Typecheck: 0 errors. Lint: clean. Existing tests: all green. The static checks caught nothing — because there was nothing in the code for them to catch.
The rule “an adjustment amount accepts negatives and decimals” was written nowhere. The type was just number (which neither requires nor forbids negatives and decimals); there was no test asserting the behavior, and not one comment. That rule lived only in the running screen’s behavior — and in the head of whoever remembered it.
So the Number() wrapper didn’t look like wrong code. If a field is typed number, coercing its input to a number is the natural move. Type-correct, behavior-wrong — and there was no signal in the code to flag the “behavior-wrong” half. The tools didn’t fail to catch it; there was simply no target for them to catch.
In the end this wasn’t so much a failed type refactor as the kind of failure you get when an implicit domain rule isn’t captured anywhere in the code. Legacy codebases are full of such rules, and you can’t know upfront which one a given change will trip.
5. What I should have done — still figuring it out
Honestly, I don’t have a clean answer yet. I’m still thinking about how I should have approached it.
There are directions. The point isn’t “know every domain rule” — in a large legacy codebase that’s impossible. It’s closer to making the invisible rules visible. Writing a test that pins the current behavior (negatives, decimals, empty, and so on) before touching a field encodes that rule into the code. Keeping the value as a string at the input boundary and only converting at submit/calculate time means this class of regression mostly doesn’t happen in the first place. I did add guards like these to the project afterward.
There’s a limit to that, though: to write such a test, you have to know, at least once, that the field takes negatives and decimals. So there’s no perfect automated defense — the larger and more mechanical the change, the more it leaves a step where a human has to actually run it and check.
One thing this made concrete: changing a single line of type can silently change what a user actually experiences. So I now treat refactors that touch behavior or UI more carefully — not out of fear, but as a posture of trusting even a spotless green build a little less as proof that “behavior is unchanged.”