Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

"Uncertain" Is a Valid Answer: Building an Ingredient Checker That Doesn't Guess

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

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?

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.