Most "is it X?" checkers return a yes or a no. That's clean UI — and often wrong. When I built Is This Vegan?, a free checker for products, ingredients and E-numbers, the most important design decision was adding a third answer: uncertain.
Here's why, and how the classification logic works.
Why binary answers fail for food
Some ingredients are clearly animal-derived: gelatin, whey, casein, honey, carmine (E120). Others are clearly plant-based. But a meaningful set depend on the source, and labels usually don't say which:
- E471 (mono- and diglycerides) — can come from plant oils or animal fats.
- E322 (lecithin) — usually soy in mass-market foods, but egg lecithin exists.
- E631 — may be fermented from plants or derived from animal tissue.
- "Natural flavors", glycerin, stearic acid — same problem.
A checker that labels these "vegan" is guessing. One that labels them "not vegan" is also guessing. So the model has three states.
The data model
type Status = "vegan" | "not_vegan" | "uncertain";
interface Ingredient {
id: string;
names: string[]; // aliases: "e471", "mono- and diglycerides of fatty acids"
status: Status;
typicalSource?: string; // "Animal collagen (bones, skin)"
note?: string; // why it's uncertain
}
Aliases matter a lot: EU/UK labels tend to use E-numbers, US labels chemical names, and dairy hides under names like whey, casein, lactose, milk powder and butterfat.
Normalizing an ingredient list
Ingredient lists are messy: parentheses, percentages, "contains 2% or less of", mixed case, and sub-ingredients. A small normalizer turns them into tokens you can match:
export function tokenize(list: string): string[] {
return list
.toLowerCase()
.replace(/\d+(\.\d+)?\s*%/g, "") // drop percentages
.replace(/contains \d+% or less of:?/g, ",")
.split(/[,;()\[\]]+/) // flatten sub-ingredient lists
.map((s) => s.replace(/\s+/g, " ").trim())
.filter(Boolean);
}
E-numbers get normalized too (E 471, e-471 and E471 should all match).
Aggregating to a product verdict
The product result is the "worst" status found, with an important ordering: a single definite animal ingredient makes the product not vegan; otherwise, any uncertain ingredient makes it uncertain.
export function verdict(tokens: string[], lookup: (t: string) => Ingredient | undefined) {
const found = tokens.map(lookup).filter(Boolean) as Ingredient[];
if (found.some((i) => i.status === "not_vegan")) return { status: "not_vegan", reasons: found.filter((i) => i.status === "not_vegan") };
const unsure = found.filter((i) => i.status === "uncertain");
if (unsure.length) return { status: "uncertain", reasons: unsure };
return { status: "vegan", reasons: [] };
}
Always return the reasons, not just the status. "Uncertain because of E471 — source not stated" is actionable; "Uncertain" alone isn't.
Edge cases worth modeling explicitly
- "May contain" warnings describe cross-contamination risk, not ingredients. They're shown separately, not counted in the verdict.
- Regional formulations — the same brand can use different recipes in the US, UK and EU, so product records need a market field.
- Reformulations — manufacturers change recipes quietly, so product records carry a "last verified" date.
- Certification (e.g. Vegan Society, V-Label) can resolve uncertain additives, since the certifier has checked the supply chain.
- Allergies are a different question. Vegan status isn't allergen safety, and the UI says so.
UX: make uncertainty useful
An uncertain result should tell people what to do next: check the package they're actually holding, look for certification, or contact the manufacturer with the product name, batch code and country of purchase.
Takeaways
- If the real world has three answers, your data model should too.
- Store aliases aggressively; labels use many names for the same thing.
- Return reasons alongside verdicts.
- Keep "may contain" and allergens out of the core classification.
Try it at isthisvegan.net. Have you built classifiers where "unknown" turned out to be the most important state?
Top comments (0)