My car loan calculator would not answer the most ordinary question anyone asks about a car loan.
Vehicle price 25,000. Interest 8%. Term 5 years. Down payment: nothing, because I am putting nothing down.
Please fill in all fields with valid numbers.
Every field is filled in. Three of them hold numbers and the fourth holds the truth. The tool has everything it needs, and it has told the user that they are the problem.
Here is the whole of it:
mortgage: function (v) {
var p = parseFloat(v.price) - parseFloat(v.down),
r = parseFloat(v.rate) / 100 / 12,
n = parseFloat(v.years) * 12;
if (!(p > 0) || isNaN(r) || !(n > 0)) return err();
...
}
parseFloat('') is NaN. NaN - 25000 is NaN. And NaN > 0 is false, quietly, with no warning and no exception — so the empty field walks straight into the same branch as genuinely broken input and comes out wearing its message.
Three different situations, one guard
That one if is doing three unrelated jobs, and I only noticed when I started listing what it rejects:
| what the user did | what they meant | what they got |
|---|---|---|
| left down payment blank | "nothing down" | fill in all fields |
| down payment = price | "I'm paying cash" | fill in all fields |
| down payment > price | "my trade-in is worth more" | fill in all fields |
| price blank | actually missing | fill in all fields |
The last row is the only one where the message is even close to true. The first three are not errors at all. They are answers: there is no loan, there is no interest, the monthly payment is zero, and in the third case the dealer owes you money.
The calculator knew all of that. It threw it away because p <= 0, and p <= 0 had been quietly redefined as "the input is bad" when what it actually means is "there is nothing to finance".
I checked whether it was just this one
It is one line in one engine, so before writing anything I wanted to know how much of the site had the same shape. There are 112 calculators sharing 111 engines, all pure functions of an input object, so sweeping them is cheap:
for (const [slug, d] of Object.entries(defs)) {
const base = {}; // each calculator's own defaults
for (const i of d.inputs) base[i.id] = i.value;
for (const i of d.inputs) {
const out = run(d.engine, { ...base, [i.id]: '0' });
if (out.error) refused.push([slug, i.id, out.error]);
}
}
Zero, not blank. Zero is unambiguously a number, and the field is unambiguously filled in, so any complaint about empty fields is unambiguously wrong.
calculators swept : 112
field = 0 refused : 123
and of those 123 refusals:
114 Please fill in all fields with valid numbers.
2 Enter two non-zero whole numbers.
2 Enter whole numbers; denominators cannot be zero.
1 a cannot be 0 — that is a linear equation, not quadratic.
1 Original value cannot be zero.
...
The nine at the bottom are fine. They refuse, they say what they refused, and one of them teaches you something. The 114 above them are a sentence that is false on its face, shown to someone who has already done the thing it is asking for — which sends them off to check the three fields that are correct.
But be honest about the scale
It is tempting to stop there and call it 114 bugs. It is not 114 bugs, and the difference matters.
Most of those 114 are refusals that should happen. A BMI with height 0, a bill split between 0 people, a loan of 0 — those have no answer, and refusing is right. Only the message is wrong.
The smaller and worse category is the one where the tool could have answered and did not. I could not detect that mechanically, so I picked it out by hand: fields that are an adjustment to a main quantity rather than the quantity itself — a down payment, a fee, an employer match, a trade-in. Leaving one of those out is a statement, not an omission.
optional-adjustment fields that refuse a blank or a 0: 4
Four. Out of 112 calculators. That is a small number and I am not going to dress it up as a big one.
Two of the four were the mortgage calculator and the car loan calculator — the two most-visited calculators on the site. Rarity and importance are different axes, and a bug census that only counts is measuring the wrong one.
The fix, which is mostly just splitting the line up
var price = parseFloat(v.price),
down = optional(v.down),
r = parseFloat(v.rate) / 100 / 12,
n = parseFloat(v.years) * 12;
if (!(price > 0)) return err('Enter a price greater than zero.');
if (isNaN(r)) return err('Enter an interest rate.');
if (!(n > 0)) return err('Enter a loan term of at least one year.');
var p = price - down;
if (p <= 0) { // paying cash, or a trade-in worth more
return { primary: { label: 'Monthly payment', value: fmt(0, 2) }, ... };
}
with
function optional(s) { var n = parseFloat(s); return isFinite(n) ? n : 0; }
Splitting the guard is not tidiness. A single message could not name the field at fault, because the code had never worked out which field it was. The specific messages are not a wording improvement layered on top of the old check — they only become possible once the check is separated.
The part I find hardest to defend is that optional() was already in the file. Here is retirement401k, unchanged, before I touched anything:
var bal = parseFloat(v.balance) || 0, // optional
salary = parseFloat(v.salary),
cpct = parseFloat(v.contribution), // not optional. same line.
mpct = parseFloat(v.match) || 0, // optional
Opening balance: optional. Employer match: optional. Contribution, sitting between them in the same var statement: not. Contributing nothing this year is a number, and the projection still exists. The right idiom was six characters away and had been for months — because it was an idiom and not a name. Naming it is most of what stops it drifting again.
Testing the two halves separately
check('nothing down is answered, not refused', money(car({ down: '' })), '506.91');
check('nothing down equals an explicit zero', money(car({ down: '' })), money(car({ down: '0' })));
check('paying cash costs nothing a month', money(car({ down: '25000' })), '0.00');
check('trade-in over the price shows change', row(over, 'Left over'), '5,000.00');
check('missing price names the price', car({ price: '' }).error, 'Enter a price greater than zero.');
check('missing rate names the rate', car({ rate: '' }).error, 'Enter an interest rate.');
Two groups, because they fail for different reasons. The first breaks if someone reintroduces a truthiness check on an optional field. The second breaks if someone collapses the guards back into one line to save space. A single "does it error" assertion would pass while either happened.
And one assertion that is about the message rather than the maths:
check('the fallback no longer claims fields are empty',
/fill in all fields/i.test(generic.error || ''), false);
What I would take from it
An error message is an assertion about the world, and it can be false. NaN > 0 === false is not a bug in JavaScript; treating that false as "the user made a mistake" is a bug in me. The code never established that a field was empty. It inferred it from a comparison that fails for four different reasons, and reported the inference as a fact, in the second person.
The one to watch for is a guard whose condition is about the result — p <= 0, !isFinite(x), total === 0 — but whose message is about the input. Those two are not connected by anything except habit, and every so often the result is zero because zero is the answer.
I build Utilorax, a set of free browser-based tools. This came out of the car loan calculator, which will now tell you what a car costs when you put nothing down — and what it costs when you pay cash, which is nothing.
Top comments (0)