The measurement is the product
In a configurator that sells by dimension, the measurement is not a detail of the order. It is the product. There is no variant called 23-3/8 inches; there is a material, a thickness, and two numbers the customer typed, and everything downstream — the price, the cart line, the production ticket — is derived from those numbers.
Which means the numbers have to mean one thing everywhere. The Custom Floating Shelves configurator sells shelves from 3 by 9 inches up to 16 by 96, in thicknesses from 1-3/4 inches to 6, and the case study’s engineering notes put the requirement plainly: the configurator supports dimensional ranges and fractional measurements “while preserving consistent pricing lookup and cart representation”. That is the whole job in one clause, and it is harder than it reads.
The floats are fine. The strings are not
The first instinct is to blame floating point, and for ruler fractions it is the wrong instinct. Halves, quarters, eighths and sixteenths are all exact in binary: 23 plus three-eighths is 23.375 to the last bit, every time. A configurator storing 23.375 as a number has not lost anything.
What goes wrong is the text. A customer can write one size several ways, and Shopify’s cart treats line items with the same variant but different properties as separate lines. Two shelves that differ only in how the width was spelled sit in the cart as two lines rather than one with a quantity of two. Properties are also replaced wholesale whenever a cart request includes them, so a later update that writes the width back in a different format quietly changes what the order says.
And the decimal that is not a fraction is a real hazard. Somebody types 23.3. That is not a mark on any tape measure in the United States; it is either a typo for 23.375, a metric habit, or a guess. A configurator that rounds it to the nearest sixteenth has made a decision about a piece of furniture on the customer’s behalf.
Parse once, at the edge
So the measurement is parsed once, at the point the customer enters it, into an integer count of sixteenths of an inch, and nothing after that point ever sees the customer’s spelling again.
The parser accepts the honest forms — 23 3/8, 23-3/8, 23.375, the ⅜ glyph, a trailing inch mark — and refuses the rest. A decimal is accepted only if it lands exactly on a sixteenth. A fraction is accepted only if its denominator is a ruler’s: two, four, eight or sixteen. Three-sevenths is refused, not approximated, because nobody’s tape is marked in sevenths and a value nobody could have measured is a value somebody mistyped.
The same function has to run everywhere the measurement is read, not only where it is typed. The browser runs it to give the customer immediate feedback; whatever processes the order runs it again, because a cart’s copy of a property is just text that can be rewritten after the fact. Two parsers that disagree about one edge case — a trailing space, the ⅜ glyph — produce exactly the inconsistency the parsing was meant to remove.
Add-ons make this sharper. On the CFS build, base products and paid add-ons can appear as separate priced lines in the cart, each preserving the configuration context. An add-on that belongs to a particular shelf has to carry that shelf’s size in the same canonical form as the shelf’s own line, or nothing downstream can tell which add-on goes with which board. Matching by spelling is matching by luck.
/** Every length is held as an integer count of sixteenths of an inch. */
const PER_INCH = 16;
const GLYPH: Record<string, string> = {
"¼": "1/4", "½": "1/2", "¾": "3/4", "⅛": "1/8", "⅜": "3/8", "⅝": "5/8", "⅞": "7/8",
};
/** Parse what a customer typed into sixteenths, or refuse. Never round a guess. */
export function parseInches(raw: string): number {
let s = raw.trim().replace(/(["”]|in(ches)?)$/i, "").trim();
s = s.replace(/[¼½¾⅛⅜⅝⅞]/g, (g) => " " + GLYPH[g]).trim();
// 23.375 — decimals are accepted only if they land exactly on a sixteenth.
let m = /^(\d+)(?:\.(\d+))?$/.exec(s);
if (m) {
const sixteenths = Number(s) * PER_INCH;
if (!Number.isInteger(sixteenths)) throw new Error(`"${raw}" is not a whole number of sixteenths`);
return sixteenths;
}
// 23 3/8, 23-3/8, or 3/8 on its own.
m = /^(?:(\d+)[\s-]+)?(\d+)\/(\d+)$/.exec(s);
if (!m) throw new Error(`"${raw}" is not a measurement`);
const [whole, num, den] = [Number(m[1] ?? 0), Number(m[2]), Number(m[3])];
if (![2, 4, 8, 16].includes(den)) throw new Error(`"${raw}" is not a ruler fraction`);
if (num >= den) throw new Error(`"${raw}" has an improper fraction`);
return whole * PER_INCH + num * (PER_INCH / den);
}
/** The one spelling used everywhere after parsing: cart, order, cut list. */
export function canonical(sixteenths: number): string {
const whole = Math.floor(sixteenths / PER_INCH);
let num = sixteenths % PER_INCH;
let den = PER_INCH;
if (num === 0) return `${whole}"`;
while (num % 2 === 0) { num /= 2; den /= 2; }
return whole === 0 ? `${num}/${den}"` : `${whole}-${num}/${den}"`;
}
/** The size the price table is looked up at: rounded up to its step, never down. */
export function priceKey(sixteenths: number, stepSixteenths: number): number {
if (!Number.isInteger(stepSixteenths) || stepSixteenths < 1) throw new Error("step must be a positive integer");
return Math.ceil(sixteenths / stepSixteenths) * stepSixteenths;
}Round up, and say so
The price table is the other place a measurement gets bent. Few businesses price every sixteenth separately; the table steps by the inch, or the half inch, and a width between steps has to be looked up somewhere.
The rule in the code is to round up to the next step, never down, and to treat the step as a business decision rather than a constant buried in a function. Rounding down means a 23-1/16 inch shelf is priced as a 23 inch one, and across a catalogue of long shelves that is a slow, permanent discount nobody chose. Rounding up means the customer pays for the next increment of timber, which is usually what the workshop actually cuts from. Either way, the size the customer typed is what goes on the cut list. Only the price lookup is stepped.
One spelling downstream
After parsing, there is exactly one spelling: 23-3/8", reduced, generated by the same function every time. That string is what goes into the line item properties, what appears on the order, and what the workshop reads. Six sixteenths becomes three-eighths before anybody sees it.
It is also what any protection on the configured price has to cover. If the price is signed or verified on its way to the cart — the problem of where a computed price is allowed to live — the canonical string is the thing being signed, so the same shelf always produces the same signature rather than one per spelling.
What this does not handle
It is inches only. A store selling in millimetres has an easier parser and the same canonical-form problem, and a store selling in both has a conversion step that is genuinely lossy — 23-3/8 inches is 593.725 millimetres, which nobody can cut — and needs its own rounding rule.
It does not check ranges. Whether 3 inches is too shallow for a given material, or 96 too long for a given thickness, is the material’s rule rather than the measurement’s, and on the CFS build material-specific restrictions sit in their own layer for exactly that reason.
And it does not decide the step. Whole inches, half inches or sixteenths is a pricing decision, and the code takes it as an argument because it will change.
When this needs an engineer
It does not, if every dimension is a fixed choice from a list. A drop-down of 24, 30 and 36 inches is three variants or three property values, and the spelling problem cannot arise because the customer never types anything.
It does when customers type the measurement, when the same size can reach the cart by more than one route, and when the order becomes a production instruction that somebody acts on with a saw. That is the configurator work in our configurator case studies, and it is what product configurator development has to get right before it gets anything else right.
Send us the store and the symptom.
---
