DEV Community

Damion Gayle
Damion Gayle

Posted on

Your Calculator App Has a Units Problem

Your Calculator App Has a Units Problem
Most calculator apps are five lines of arithmetic wrapped in three hundred lines of UI. That ratio is exactly why they break. The arithmetic is trivial, so nobody reviews it — and the bug that ships is almost never a math bug. It's a units bug.
I spent a few weeks last year rebuilding an estimator for a civil-engineering domain, and the whole project turned into one long lesson about how weakly typed numbers rot a codebase. Here's what I'd do differently from line one.
A number without a unit is a lie
Here's the shape of code I see in almost every calculator repo on GitHub:
function calculateWeight(area, thickness, density) {
return area * thickness * density / 1000;
}

Reasonable. Also completely undefended. Is thickness in millimetres or metres? Is density in kg/m³ or lb/ft³? The function has no idea, and neither does the caller six modules away who just wired up an input field labelled "Thickness."
Pass 50 (mm) where the function expects 0.05 (m), and you don't get an error. You get an answer that's 1000× too large, rendered confidently in a nice card with a currency symbol next to it.
The fix isn't clever. It's just tagging:
`const mm = (v) => ({ value: v / 1000, unit: 'm' });
const m = (v) => ({ value: v, unit: 'm' });

function volume(area, thickness) {
if (area.unit !== 'm2' || thickness.unit !== 'm') {
throw new TypeError(unit mismatch: ${area.unit} × ${thickness.unit});
}
return { value: area.value * thickness.value, unit: 'm3' };
}
Verbose? Yes. But every conversion now happens at exactly one boundary — the input parser — instead of being scattered through the call graph as bare /1000 and * 0.3048 sprinkles. If you're on TypeScript, branded types get you the same safety with zero runtime cost:
type Metres = number & { readonly __unit: 'm' };
type Millimetres = number & { readonly __unit: 'mm' };
The bug that taught me this
The domain I was working in has two materials with confusingly similar names: the sticky black binder, and the finished road mix that binder goes into. One weighs about 1,020 kg/m³. The other weighs about 2,350 kg/m³.
They're not interchangeable, and yet plenty of published tools put the mix figure behind a field labelled with the binder's name. The result: an order that's roughly 130% too high, or one that's less than half of what the site needs. On a 400-tonne job that's a five-figure error, produced by a tool that never threw a single exception.
That's the thing about domain calculators. Your test suite passes. Your types check. Your answer is wrong. The only defense is encoding the domain distinction into the code itself, rather than hoping the label on the input is enough:
const DENSITY = {
BINDER: { value: 1020, unit: 'kg/m3', appliesTo: 'binder' },
MIX: { value: 2350, unit: 'kg/m3', appliesTo: 'mix' },
};

function toVolume(mass, density) {
if (mass.material !== density.appliesTo) {
throw new Error(cannot apply ${density.appliesTo} density to ${mass.material});
}
// ...
}
Now the wrong pairing is a crash in development instead of a purchase order in production. This kind of guard rail is what separates a toy from a tool — and it's the reason I ended up rebuilding the whole thing as the [Bitumen Calculator](https://bitumencalculator.org/), where the two densities are tracked as separate quantities all the way through and both are shown in the output rather than silently collapsed into one number.
Two calculations wearing the same costume
The second structural mistake: assuming one input schema covers the domain.
In my case, material gets applied two entirely different ways. Rolled into a layer, where you know the thickness and compute a volume. Or sprayed onto a surface, where there is no meaningful thickness — the spec gives you a coverage rate in litres per square metre directly.
Modelling that as one form with an optional field is how you get a tool that produces confident nonsense. A sprayed coat is about a third of a millimetre thick. Enter that as a layer thickness and you'll either trip a minimum-value validator or get a tonnage for a layer that doesn't exist.
Discriminated unions, not optional fields:
const job =
{ mode: 'layer', thicknessM: 0.05, mixDensity: 2350, binderPct: 5.5 }
// or
{ mode: 'spray', rateLPerM2: 0.30, residueFraction: 0.60 };
Two modes, two schemas, two validators. The UI swaps the entire input set when the mode changes, because these are genuinely different calculations — not two views of one.
The conversion nobody documents
Last one, and it's the most expensive kind of bug: the transformation that lives in the domain but not in the spec sheet.
Sprayed material is usually an emulsion — the actual binder suspended in water, typically 55–70% by mass. The specification quotes what's left after the water evaporates. So the quantity you order is not the quantity in the spec:
const productLitres = residualLitres / residueFraction;
// 0.30 / 0.60 = 0.50 L/m²`
Skip that division and you're 40% short on site. It's one line of code. It's also the line that every naive implementation omits, because it isn't arithmetic — it's domain knowledge, and you only get it by reading the standards or talking to someone who does the work.
Which is the real takeaway. The formula is the easy part; it's a multiplication. What makes a domain calculator trustworthy is everything around the formula: units carried explicitly, mutually exclusive modes modelled as such, and the unglamorous conversions that only exist in a PDF someone handed you.
A checklist, if you're building one

Tag every quantity with its unit. Convert only at the I/O boundary.
Validate ranges against physical reality, not just > 0. A 5,000 mm asphalt layer is a typo, not a road.
If the domain has two calculation methods, build two forms.
Put your defaults in one exported constants module and let users edit them.
Show intermediate steps. When a number looks wrong, users need to see where it diverged.
Keep it client-side if you can. Field users lose signal, and nobody wants a spinner at a job site.

None of this is advanced engineering. It's just taking the boring half of the problem as seriously as the interesting half — which, in calculator apps, is where all the actual bugs live.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

"I've run into this issue with my own calculator app too - how do you handle cases where the user enters a calculation with different units (e.g. inches + feet)? Your approach to parsing the input expression seems elegant, would love to hear more about how you handle unit conversions in the backend."