Diet is not a ladder
Vegan is not a stricter vegetarian, and Jain is not a stricter vegan. Model diet as one slider and you will quietly serve people food they will not eat.

If you have ever built a food feature, you have probably written this enum, or one very like it:
vegan < vegetarian < eggetarian < pescatarian < omnivore
It is a lovely little type. It is ordered, so filtering is a comparison. A vegan user gets everything at or below vegan, an omnivore gets everything. One integer on the profile, one <= in the query, done before lunch.
It is also wrong, and the way it is wrong is the worst kind: it looks right in testing, passes review, and then serves somebody a dish they cannot eat.
Where the ladder breaks
Put Jain food on that ladder and the whole thing falls over.
Jain dietary practice excludes root vegetables: onion, garlic, potato, carrot, ginger. It does not exclude dairy. Ghee is not merely permitted, it is central to a great deal of Jain cooking. So where does Jain go?
Below vegan? Then a Jain user never sees anything containing ghee, and you have taken away half of what they actually eat. Above vegan, next to vegetarian? Then the filter happily returns aloo gobi, which is potato and cauliflower, and a Jain user opens your app and finds a potato curry recommended for them.
There is no correct position on the ladder because the relationship is not an ordering. Vegan food is not a subset of Jain food and Jain food is not a subset of vegan food. A dish of onion and tofu is vegan and not Jain. A dish of dal with ghee is Jain and not vegan. Two sets that overlap without either containing the other cannot be two points on a line.
Two sets that overlap without either containing the other cannot be two points on a line.
And that is only the first axis to come loose
Halal and kosher have nothing to do with how much animal content a food has. They are about species, slaughter method, and in the kosher case combination rules about what may be eaten alongside what. A halal chicken curry and a non-halal chicken curry are identical on the animal-content ladder and completely different to the person deciding what to order.
No pork is narrower still and orthogonal to all of it. So is no beef. So is a nut allergy, which is not a diet at all but lands in the same filter.
Try to express any of these as positions on the original enum and you end up with an enum that has twenty-three values, several of which are combinations, and a comparison operator that no longer means anything.
The model we ended up with
Three fields, deliberately independent.
Animal content is a genuine ladder, and it is the only thing about diet that is: none < dairy < egg < fish < meat. It says how far up the animal-product scale a food reaches, and nothing else. This one ordering is real, so we keep it and use it.
Jain safe is an independent boolean on the food. It is not a point on the ladder, it is a separate question asked of the same food, and the answer has no relationship to the first answer.
Restrictions are a list. Halal, kosher, no pork, no beef, no nuts. A food declares what it satisfies and what it contains; a person declares what they require. Membership, not comparison.
| Dish | Animal content | Jain safe | Vegan can eat | Jain can eat |
|---|---|---|---|---|
| Aloo gobi | none | no (potato) | yes | no |
| Dal with ghee | dairy | yes | no | yes |
| Tofu and onion stir fry | none | no (onion) | yes | no |
| Steamed rice | none | yes | yes | yes |
| Paneer tikka | dairy | no (onion) | no | no |
Read the two right-hand columns down. Every combination of yes and no appears. That is the proof that no single ordering could have produced this table, and it is why the two-axis model is not over-engineering.
The rule that keeps it honest
A model like this is only worth having if nobody is allowed to route around it. The failure mode is obvious: somewhere, six months from now, a screen needs a quick filter and somebody writes food.animalContent <= profile.animalContent, which is correct for exactly the cases they tested and wrong for Jain users forever.
So there is exactly one function that answers the question, canEat(), it takes the food and the profile, and it checks all three axes. Writing a raw comparison against the animal-content enum is a review failure in our codebase. It is in the working agreement, in those words, because rules that live only in someone's head do not survive a busy week.
Why this belongs in the engine, not the UI
Our nutrition engine is pure TypeScript with no React, no React Native and no Node builtins. It runs on the phone, on the server and in tests, and it is the same code in all three. A filter that lives in a screen is a filter that exists in one of those three places and silently disagrees with the other two.
What it cost, and what it bought
The cost is real and it is mostly authoring. Every food row carries all three fields, which means every dish that goes through our drafting pipeline has to have its Jain status determined rather than inferred, and a wrong flag is not a cosmetic bug. That is one of the reasons several hundred drafted dishes are sitting in a review queue right now instead of in the app: a model that drafts is fine, a model that approves is not.
What it bought is that the app is usable by people that a ladder-shaped schema quietly excludes. A vegan in Berlin and a Jain household in Ahmedabad both get a catalog that is actually edible, without either of them having to scroll past things they will never order. Halal and kosher users get a filter that means what it says instead of a rough proxy.
The general lesson is older than this app. When you reach for an ordered enum, check whether the thing you are modelling is actually ordered, or whether it merely looks ordered from where you happen to be standing. Diet looks like a ladder from one culture's vantage point. It is not one.


