A user types 3 into a depth field. The label says inches. Your formula assumes feet.
You just shipped a 12x error, and nothing in the type system noticed, because both values are number.
I hit this class of bug repeatedly while building material calculators, so I stopped trusting unit labels and made the conversion a type-level concern instead.
One canonical unit
Pick a single internal unit and convert everything at the edge. For a US-customary-heavy calculator, feet is the natural choice:
export const units = {
in: 1 / 12,
ft: 1,
yd: 3,
cm: 1 / 30.48,
m: 1 / 0.3048,
};
export type Unit = keyof typeof units;
The values are conversion factors into the canonical unit. That is the whole trick: downstream code never sees a unit again, so there is exactly one place where a unit can be wrong — and it is a table of five constants you can eyeball.
Notice this also gives you a free Unit type. An invalid unit string is now a type error at the call site instead of a silent NaN at runtime.
Validate the unit, not just the number
The converter should reject an unknown unit and reject a non-positive dimension, and the error should name the field:
const dim = (value: string, unit: Unit, label: string) => {
if (!(unit in units)) throw new Error(`${prefix}: choose a valid unit.`);
return number(value, `${prefix} ${label}`) * units[unit];
};
The unit in units check looks redundant when the parameter is typed Unit — but the state being validated comes back from persisted, parsed JSON, where the type annotation is a claim rather than a guarantee.
export function number(value: string, label: string, zero = false): number {
const n = Number(value);
if (!value.trim() || !Number.isFinite(n) || (zero ? n < 0 : n <= 0)) {
throw new Error(
`${label}: enter a ${zero ? "non-negative" : "positive"} number.`,
);
}
return n;
}
Two things worth stealing here.
!value.trim() catches the empty string. Number("") is 0, which passes Number.isFinite and then silently becomes a zero dimension. An empty field and a genuine zero are different situations, and the form should say so.
The zero flag makes the semantics explicit. A waste allowance may legitimately be 0. A length may not. Encoding that difference in one boolean is clearer than two near-identical validators.
Watch the other quantities
Not every numeric field is a measurement. A count of beds, slabs, or retaining walls is an integer:
const q = number(a.quantity, `${prefix} quantity`);
if (!Number.isSafeInteger(q)) {
throw new Error(`${prefix} quantity: enter a whole number.`);
}
Number.isSafeInteger also rejects NaN, Infinity, and values past 2^53 — all of which would produce nonsense in a total.
Shapes without a shape library
Once units are normalized, the geometry is short enough to keep inline. Each branch differs by one or two lines:
const surface =
a.shape === "circle"
? Math.PI * (l / 2) ** 2
: l *
dim(a.width, a.widthUnit, a.shape === "triangle" ? "perpendicular height" : "width") *
(a.shape === "triangle" ? 0.5 : 1);
const volume = surface * d * q;
The label switches as the meaning of the input changes — for a circle, length is the diameter; for a triangle, width is the perpendicular height. Reusing the same three fields with different semantics is fine as long as the error messages and the UI labels change with them. Silently keeping the label "width" on a triangle is how users enter the slant height.
The guard after the guard
Floating point and unit conversion can still produce something unusable:
if (!Number.isFinite(volume) || volume <= 0) {
throw new Error(`${prefix}: dimensions are outside the supported range.`);
}
This is not paranoia. 1/30.48 multiplied through several dimensions can land on a denormal or overflow for absurd inputs. A final sanity check costs one comparison.
Why 324 shows up in mulch formulas
If you have ever seen this formula and wondered about the constant:
length(ft) × width(ft) × depth(in) ÷ 324 = cubic yards
It is not arbitrary. It is two conversions folded together:
- inches to feet: divide by 12
- cubic feet to cubic yards: divide by 27
So 12 × 27 = 324. Once you normalize units at the boundary, you never write 324 again — the constant disappears into the unit table, which is exactly where it belongs.
Validation as a first-class output
The pattern that made these calculators maintainable is that invalid input produces a labeled error, not a zero and not a NaN:
Area 2 depth: enter a positive number.
Area 1 quantity: enter a whole number.
That string is useful in the UI, useful in a test assertion, and useful when someone reports a bug six months later. A function that returns 0 on bad input is a function that will be debugged by reading it line by line.
The checklist
- One canonical unit. Convert once, at the edge.
- Derive a
Unittype from the conversion table. - Reject empty strings explicitly — do not let
""become0. - Distinguish "may be zero" from "must be positive".
- Use
Number.isSafeIntegerfor counts. - Relabel a field when its meaning changes (diameter, perpendicular height).
- Sanity-check the final result, not just the inputs.
- Throw errors that name the field.
I ended up extracting this into Cubic Yard Tools, a set of material calculators for landscaping and construction that accepts both US customary and metric input. The conversion table and the validator above are the parts I would copy first into any new calculator.
Disclosure: Cubic Yard Tools is my own project. Estimates there are planning aids, not engineering approvals.
Top comments (1)
Good pattern. One addition for the persisted-state side you mentioned: store the raw value and unit the user typed ({ value: "3", unit: "in" }), not the converted feet. If you persist 0.25 ft and later re-display it in inches or cm, you get rounding drift (0.3333 ft back to 3.99996 in) and the form no longer shows what the user entered.
And a tiny table test pays for itself here: 12 in = 1 ft, 3 ft = 1 yd, 30.48 cm = 1 ft, 1 m = 3.28084 ft. If someone "fixes" a factor to 1/30 or 3.28, it fails right away instead of shipping a 1.6% error nobody notices.