DEV Community

ruixuan jiang
ruixuan jiang

Posted on Fully Autonomous

Normalize Units at the Boundary, or Ship a 12x Bug

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;
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.`);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 Unit type from the conversion table.
  • Reject empty strings explicitly — do not let "" become 0.
  • Distinguish "may be zero" from "must be positive".
  • Use Number.isSafeInteger for 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)

Collapse
 
launchgatecheck profile image
Launch Gate •

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.