Most online calculators give you a number box and a unit dropdown. Then someone measures their patio with a tape, gets 12 feet 6 inches, and has to work out that that's 12.5 before they can type anything.
I build Toggle9, a set of free calculators, and I wanted the input box to accept what people actually write down. This post walks through the parser behind it: under 200 lines of plain JavaScript, no dependencies.
What it has to accept
These are real inputs and the values the parser returns (field default unit in brackets):
| Typed | Result |
|---|---|
12' 6" |
12.5 ft |
12'-6 1/2" |
12.5417 ft |
12 1/2 or 12½
|
12.5 ft |
2.4 m (in a feet field) |
7.874 ft |
2,5 (in a metres field) |
2.5 m — decimal comma |
1,250 |
1250 — thousands separator |
12' + 3' 6" |
15.5 ft |
(12 + 4) * 2 |
32 ft |
12' x 10' (area field) |
120 sq ft |
150 sq ft (square metres field) |
150 sq ft, stored as m² |
And it has to refuse some things, with a message a person can act on:
| Typed | Message |
|---|---|
12 sq ft in a length field |
That looks like an area; this field needs a length. |
12 apples |
I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6". |
Step 1: normalise the mess
Before parsing anything, fold the many ways of writing the same thing into one:
const VULGAR = { '½': '1/2', '¼': '1/4', '¾': '3/4', /* … */ };
export function normalise(raw) {
let s = String(raw ?? '').trim();
for (const [k, v] of Object.entries(VULGAR))
s = s.replace(new RegExp('(\\d)?' + k, 'g'), (m, d) => (d ? d + ' ' : '') + v); // 12½ -> 12 1/2
s = s.replace(/[×xX](?=\s*[\d(.])/g, '*').replace(/÷/g, '/').replace(/[−–—]/g, '-');
s = s.replace(/(\d),(\d{3})(?!\d)/g, '$1$2'); // 1,250 -> 1250
s = s.replace(/(\d),(\d)/g, '$1.$2'); // 2,5 -> 2.5
s = s.replace(/(\d)-(\d+\/\d+)/g, '$1 $2'); // 3-1/2 -> 3 1/2
s = s.replace(/(['’′]|\bft|\bfeet|\bfoot)\s*-\s*(?=\d)/gi, '$1 '); // 12'-6" -> 12' 6"
return s;
}
Two details matter here:
-
xonly becomes multiply when a number follows it, so12 x 10is a product but thexinside a unit word is left alone. -
The comma rule runs in a fixed order.
1,250(comma before exactly three digits) is a thousands separator; any otherdigit,digitis a decimal comma. That reads2,5correctly for the many people who write decimals that way, and it's predictable.
Step 2: lex numbers, fractions and units
The lexer produces three kinds of token: numbers, operators and units. Fractions are folded into the number while lexing, because 1/2 next to a number is part of the number, not a division:
const num = /^(\d+(\.\d+)?|\.\d+)/.exec(s.slice(i));
if (num) {
let v = parseFloat(num[0]); i += num[0].length;
const frac = /^\/(\d+(\.\d+)?)/.exec(s.slice(i)); // tight a/b is a fraction
if (frac) { v = v / parseFloat(frac[1]); i += frac[0].length; }
else {
const mixed = /^\s+(\d+)\/(\d+)/.exec(s.slice(i)); // 3 1/2
if (mixed && Number.isInteger(v)) { v += parseInt(mixed[1]) / parseInt(mixed[2]); i += mixed[0].length; }
}
t.push({ k: 'num', v });
}
So 48 in / 2 (spaces around the slash) is division, while 1/2 is a fraction.
Units are matched longest-first so sq ft wins over ft, and volume and area words are tried before length words. Each unit token carries its dimension: the power of length (1 = length, 2 = area, 3 = volume). Quote marks (', ", ′, ″, and their curly versions) count as feet and inches, which matters on phones that autocorrect ' into ’.
Step 3: evaluate with dimensions
The evaluator is a small recursive-descent parser (sum → product → quantity) where every value is { v, dim }, always in SI base units (metres, m², m³). Multiplying adds dimensions, dividing subtracts them:
if (op === '*') a = { v: a.v * b.v, dim: a.dim + b.dim };
else a = { v: a.v / b.v, dim: a.dim - b.dim };
That is how 12' x 10' becomes an area: length × length = dimension 2, so the area field accepts it and a length field rejects it.
Two rules make it feel natural rather than strict:
Adjacent pieces add up. 5' 10" is two quantities side by side with no operator, and people mean "plus". Inside quantity(), while the next token is a number and we're holding a length, keep adding. If the previous unit was feet and the next number has no unit, assume inches:
while (peek()?.k === 'num' && q.dim === 1) {
const n = take(); let add;
if (peek()?.k === 'unit' && peek().dim === 1) { const u = take(); add = n.v * LENGTH[u.u]; }
else if (q.units.at(-1) === 'ft') add = n.v * LENGTH.in; // 5' 10 -> 5' 10"
else throw new Error('Add a unit to the second number, e.g. 5 ft 10 in.');
q = { ...q, v: q.v + add };
}
Bare numbers borrow the field's unit, but only when they need to. In 12' + 4, the 4 has no unit. Adding a length to a plain number would be a dimension error, so plain numbers in a sum are "lifted" to the field's default unit first. In (12 + 4) * 2 nothing has a unit, so the whole thing is evaluated as a plain number and lifted once at the end: 32 ft.
Step 4: errors a person can fix
Every failure returns { ok: false, error } with a sentence the UI shows under the box. Three kinds of error cover nearly everything:
- Can't read it: "I couldn't read "apples". Try 12.5, 12 1/2 or 12' 6"." The examples matter more than the error itself.
-
Wrong kind of quantity: "That looks like an area; this field needs a length." This catches
12 sq fttyped into a length box, which a plainparseFloatwould happily turn into 12. - Out of range: "Use a value of 10 or less." Range checks run on the value in the field's unit, not in metres, so the numbers in the message match what the person sees.
Testing it
The parser has its own test file (Node's built-in node:test, no framework), with cases taken from what people actually type: 12' 6 1/2", 12 x 10 into an area field, 2 cu yd into a cubic-feet field. Any input that turns out to be read wrongly gets added as a test case first, then fixed.
Where it's used
The same parser sits behind every measurement box on Toggle9, for example the concrete calculator (slabs, footings and post holes) and the square footage calculator. Try typing 12' 6" x 10' into an area field.
If you've built something similar, I'd like to hear how you handled the ambiguous cases. 5 10 is my current favourite: is that 5 feet 10, or a typo?
Top comments (0)