
by Theo
Writing a Tolerant Parser for my Cookbook Application
My recipe app is offline-first and mostly boring on purpose. The one part worth talking about is what happens when someone types '1 1/2 cups milk'.
Writing a Tolerant Parser for Recipe Ingredients
I’m building a recipe app. The hard parts aren’t where you’d expect them. The UI is straightforward, the data layer behaves, and the state management doesn’t keep me up at night.
The part that turned out to be genuinely interesting is the least glamorous one: when someone types an ingredient, I have to figure out what they actually meant.
The Problem
Recipes are full of freeform text. Someone writes “500 g ground beef.” Someone else writes “1/2 tsp salt.” Another writes “pinch of pepper,” or “1 1/2 cups milk,” or just “3 eggs.”
Five different shapes for what is, conceptually, the same thing: an amount, a unit, and a name.
The obvious answer is a structured form — amount in one box, unit in a dropdown, name in another. It’s clean. It’s also hostile. People don’t think about ingredients as three fields. They think “I need half a teaspoon of salt,” and they want to type that and move on.
So instead of making the user fit my data model, I wrote a parser that meets them where they are.
The Approach
It’s a pure function. String in, structured ingredient out (amount, unit, name). No dependencies, no state, nothing clever. Just a sequence of regex patterns tried from most specific to least specific.
First, a map that normalizes units. People write “grams,” “gr,” and “g” — I only care about the last one.
const _unitMap = {
'g': 'g', 'gram': 'g', 'grams': 'g', 'gr': 'g',
'kg': 'kg', 'kilogram': 'kg', 'kilograms': 'kg',
'oz': 'oz', 'ounce': 'oz', 'ounces': 'oz',
'tbsp': 'tbsp', 'tablespoon': 'tbsp', 'tablespoons': 'tbsp',
'tsp': 'tsp', 'teaspoon': 'tsp', 'teaspoons': 'tsp',
};
Then the parsing itself, tried in order:
RecipeIngredient parseIngredient(String input) {
if (input.isEmpty) {
return RecipeIngredient(amount: 1, unit: 'item', name: '');
}
final text = input.trim();
// Mixed number: "1 1/2 cups milk" -> 1.5 cups
final mixed = RegExp(r'^(\d+)\s+(\d+/\d+)\s*(\S*)\s*(.*)').firstMatch(text);
if (mixed != null) {
return RecipeIngredient(
amount: double.parse(mixed[1]!) + _parseFraction(mixed[2]!),
unit: _normalizeUnit(mixed[3]!),
name: mixed[4]!.trim(),
);
}
// ... separated, attached, amount-only, "X of Y", then:
return RecipeIngredient(amount: 1, unit: 'item', name: text);
}
The order matters. “1 1/2 cups milk” has to be checked before “1/2 tsp salt” — a mixed number is two numbers, and if you grab the first one you’re left with nonsense. And “500g flour” is a different shape from “500 g flour”, so they each get their own pattern.
The Edge Cases
The happy path is easy. The unhappy paths are where the work is:
- Fractions. “1/2” is 0.5, not a string. The fraction helper actually does the division.
- Mixed numbers. “1 1/2” is 1.5. Easy to say, fiddly to get right.
- Unknown units. “a handful of almonds” — “handful” isn’t in my map, so I preserve it as-is instead of throwing. The parser’s job is to not lose information.
- No amount. “salt to taste” becomes amount 1, unit “item”. Not exactly right, but recoverable. That’s the point.
- Empty string. Defaults instead of crashing.
The Honest Limitation
This isn’t a natural language parser. It’s a set of rules, and rules have holes. “Salt to taste” falls into the “no amount” branch and becomes a single item, which is philosophically wrong but practically fine. If someone writes something I genuinely can’t parse, I keep the original text rather than guessing.
The principle is tolerance: when in doubt, don’t lose the user’s data. A wrong-but-preserved ingredient is better than a correctly-typed one that vanished.
Why I think this is cool.
It’s not the most complex code I’ve written. It’s not clever for the sake of it. What I like is that it’s the opposite of most parsing code I’ve seen — it starts from the user and works backward, instead of starting from the data model and forcing the user to fit.
Because it’s a pure function with zero dependencies, it’s trivially testable. So I wrote twenty tests, one for every case above plus the unit normalizations. Fractions, mixed numbers, attached and separated units, count items, “of” phrases, empty strings, unknown units. All pinned down.
That’s the part I’d like to highlight. Not because it’s impressive, but because it’s the kind of small, self-contained problem where engineering judgment actually shows.