Here is a comparison table that is wrong, and wrong in a way no type checker will catch:
Self-guided audio tour $15
Shared group bus tour $100
The first is priced per group. The second is per person. For a family of four the real comparison is $15 against $400, and the table has quietly told the reader the opposite of the truth by a factor of 26.
Nothing failed. Both values are positive numbers in the right currency, they sorted correctly, and the page rendered. The schema was price: number and the bug is that a price is not a number.
A price is an amount, a currency, and a denominator
The fix is boring and structural:
type Price = {
amount: number
currency: 'USD'
per: 'person' | 'group' | 'vehicle' | 'night' | 'day'
minGroupSize?: number // some per-group rates assume a floor
}
per is not optional. The moment it is nullable, half your rows are null and you are back to guessing at render time. If a source genuinely does not say, that is a data quality problem to surface, not a default to invent.
Once the denominator is in the model, the comparison becomes a real function instead of a table sort:
function totalFor(price: Price, party: { people: number; vehicles: number }): number {
switch (price.per) {
case 'person': return price.amount * party.people
case 'group': return price.amount
case 'vehicle': return price.amount * party.vehicles
default: throw new Error(`unhandled unit: ${price.per}`)
}
}
Now "cheapest" is a question you can only answer once you know the party size, which is correct, because that is how it works in reality. A sort with no party size is not a cheaper-first sort. It is a nonsense ordering presented with confidence.
Where this bites beyond travel
The pattern shows up anywhere a catalogue mixes billing units:
- SaaS pricing: per seat against per workspace against per 1,000 API calls
- Freight: per pallet against per kilogram against per container
- Utilities: per kWh against a fixed standing charge
- Contractors: hourly against day rate against fixed bid
Each of these has produced the same table, where a column of numbers implies a comparison that the numbers do not support.
Do not fix it in the formatter
The tempting shortcut is leaving the model alone and appending the unit in the display string: "$15 per group". That fixes the one page a careful reader is looking at. It does not fix sorting, filtering by budget, the cheapest-first badge, the price-range facet, or the structured data you emit, all of which still operate on a bare number.
Unit belongs beside the amount, at the point of storage, so everything downstream inherits it.
Two things worth asserting
Add these and the class of bug largely disappears:
-
No
Pricemay be constructed withoutper. Enforce at the parse boundary, where third-party data arrives, not deep in a component. - Any UI that sorts or compares prices must require a party size. If a page cannot supply one, it should display prices without ranking them rather than rank them wrongly.
The second one is the harder sell, because "cheapest first" is a product expectation. It is also the one that keeps you from confidently recommending the most expensive option.
A worked example of the display side, showing per-group and per-person tiers held apart rather than merged into one ranking, is the Mount Rushmore tour price breakdown, where the cheapest tier is per group and every tier above it is per person.
Top comments (0)