Munchable tells someone with IBS or reflux whether a supermarket product fits their eating pattern. In September it started warning about declared allergies too. An allergy is dangerous in a way a FODMAP is not, so the allergen code got a design rule the condition code does not have: it only ever adds a warning. It never clears a product, never scores an allergen on its own merits, and never reports an allergen as absent.
The interesting part is that the rule is enforced three times, in three different materials, and the hardest of the three is a type.
Enforcement one: the type
/**
* How sure the data is that an allergen is present. There is deliberately no
* third value for "absent": the allergen layer only ever adds warnings, and
* the screen words a missing hit as "not mentioned", never as "free from".
*/
export type AllergenLevel = 'contains' | 'may-contain';
No code path can construct "absent", because the vocabulary does not contain it. The result type has two fields, declared and hits, and an empty hits array means the data said nothing, not that the product is safe.
Enforcement two: the engine's combination step
The allergen check touches the verdict in exactly one place, and that place is monotone downward:
const allergens = profile.allergens?.length
? checkAllergens(product, profile.allergens)
: undefined;
if (allergens) {
if (allergens.hits.some((h) => h.level === 'contains')) verdict = 'avoid';
else if (allergens.hits.length > 0 && verdict === 'good') verdict = 'caution';
}
A contains hit is an avoid whatever the conditions made of the product and however little of the label was read. The comment above it puts it well: a "can't assess" that lists peanuts is still a product to put back. Precautionary advice keeps the product off green and no more, because "may contain" is the user's call to make, and the notice puts the words in front of them.
Note the ternary. The check is not even called unless the profile declares an allergy, and the result key is absent for profiles that do not. Nothing about allergies is computed, stored or displayed for the majority of users who did not ask.
Enforcement three: the data file's header
The map from allergens to ingredient tags opens with this, in capitals in the source:
THIS LAYER ONLY EVER ADDS A WARNING. It never clears a product. A missing allergen in the data is silence, not safety: labels change, reads miss words, and "may contain" advice is printed in places a photo can crop out.
Fourteen allergens, the EU and UK list, because it is a superset of the US "big nine" and one list for every region means a user who travels does not lose a warning at the border. The exclusions are documented as carefully as the inclusions: coconut is not a tree nut under EU law, and en:cereal is not gluten because rice and maize are cereals too.
Two paths to a hit, and the generic word problem
An allergen can be found by walking the ingredient tags, or by reading the label's own "Contains:" statement. The ingredient path has a subtlety:
for (const source of ALLERGEN_SOURCES[allergen]) {
if (!present.has(source.tag)) continue;
// "Nuts" on a label is evidence; `en:nut` inferred from `en:almond` is
// not evidence of peanuts. A generic source counts only when the pack
// printed that word itself.
if (source.generic && !printed.has(source.tag)) continue;
hit = { allergen, level: 'contains', via: 'ingredient', ingredientTag: from ?? source.tag };
break;
}
Normalisation expands almond to its parent en:nut. If the generic nut tag counted as evidence of peanuts, every almond biscuit would warn a peanut allergy. So a generic word counts only when the label printed it, and that is a question only the printed-tags set can answer. A regression test called "a generic word counts wherever it is printed, not just when it is printed first" exists because the first version got the ordering wrong.
Parsing "Contains: milk. Free from: gluten"
The statement parser reads one sentence at a time, because a statement is very often two claims of opposite sign in one string, and the punctuation between them is the only thing that says where one ends.
for (const sentence of text.split(/(?<=[.;!?])\s+|\n+/)) {
let body = normalizeToken(sentence)
// "egg (free range)" is not a claim that the product is egg free.
.replace(/\bfree (?:range|run)\b/gu, ' ');
// "No nuts", "no milk": a sentence that opens with a bare negative is a
// claim about absence from its first word.
if (/^no\s/u.test(body)) continue;
// "<allergen> free" suppresses that allergen for this sentence only, so
// "gluten-free oats" neither warns about gluten nor about the oats.
const suppressed = new Set<AllergenId>();
body = body.replace(/\b(\p{L}+)[\s-]free\b/gu, (_m, word) => {
for (const id of allergensNamedBy(word)) suppressed.add(id);
return ' ';
});
...
}
Anything a sentence declares absent is dropped from that sentence, never from the whole statement, so a "free from" list can never delete a real declaration next to it. The tests cover both directions: Contains: egg (free range), milk yields milk and egg; Contains: milk. Free from: gluten yields milk only.
One more rule worth stealing: precautionary advice is cut at the phrase, not the sentence. Labels routinely print it comma-continued at the end of the ingredient list, "...salt, may contain traces of nuts", and taking the whole sentence would file every ingredient as a trace.
What the screen says when nothing was found
The app groups hits into contains, may-contain and a third presentation-only level, not-mentioned, for declared allergens with no hit. The type for that level says: "'Not mentioned' is deliberately not a verdict tier: the data can never promise absence, so it is quiet grey information, never green and never a claim."
The standing caution is on screen at every level, because a closed row must still say that "not mentioned" is not the same as "free from". A test asserts the wording never matches does not contain or safe.
See it on the site
Every page on munchable.app carries the same rule in its footer, under "Allergy warnings, not allergy safety": it adds a warning and never tells you a product is free from anything. To see the layer run, sign in, add an allergy to your profile, and scan or open any product. The result screen shows a row per declared allergy, and the ones with nothing found are grey, closed, and worded as silence.
Top comments (0)