Missing, empty, zero: three states one falsy check collapses
if (value) asks whether a value is truthy. Most of the time you meant to ask whether it was provided.
The problem
A product listing accepts optional filters. Each filter is applied only when the caller supplied it, so the natural implementation guards each one with a truthiness check.
It works in every manual test, because every manual test uses a non-empty search term and a sensible price.
interface Item { name: string; price: number }
interface Filters { search?: string; maxPrice?: number }
function apply(items: Item[], filters: Filters): Item[] {
let result = items;
if (filters.search) {
result = result.filter(item => item.name.includes(filters.search!));
}
if (filters.maxPrice) {
result = result.filter(item => item.price <= filters.maxPrice!);
}
return result;
}
const catalog = [{ name: 'Sticker', price: 0 }, { name: 'Mug', price: 12 }];
console.log(apply(catalog, { maxPrice: 0 }));PREDICT FIRST
A shopper filters for free items only — maxPrice is 0. What comes back?
Commit to an answer before you read on. Being wrong here is the part that sticks.
Why it behaves that way
The guard conflates two different questions. "Did the caller supply a maximum price?" is a question about presence. "Is the maximum price non-zero?" is a question about value. Truthiness answers the second one and gets used for the first.
The same collapse hits search: "". An empty string usually means the user cleared the box, which is genuinely "no filter" — but only by accident, and the code no longer distinguishes an intentional empty search from an omitted one.
Once you write the presence check explicitly, the remaining decisions become visible and you have to make them on purpose: is an empty search term a filter that matches everything, or a filter that matches nothing? Is null from a JSON payload the same as an absent key, or does it mean "clear this value"?
This is why ?? exists alongside ||. port ?? 8080 falls back only when port is null or undefined; port || 8080 also rewrites a deliberate 0.
function apply(items: Item[], filters: Filters): Item[] {
let result = items;
const { search, maxPrice } = filters;
// An empty search box clears the filter; a blank-but-present term is a
// decision, not an accident. Write it down.
if (search !== undefined && search.trim() !== '') {
result = result.filter(item => item.name.includes(search));
}
if (maxPrice !== undefined) {
result = result.filter(item => item.price <= maxPrice);
}
return result;
}What the change buys you
| Behaviour | Before | After |
|---|---|---|
| maxPrice: 0 | Filter skipped, whole catalog returned | Only free items returned |
| maxPrice omitted | Filter skipped | Filter skipped |
| search: "" | Skipped by accident | Skipped by a documented rule |
| The non-null assertions | Two ! needed inside the callbacks | None — narrowing survives destructuring |
Variations worth trying
- Do the same audit on
??versus||for numeric configuration: timeouts, retry counts, page sizes and rate limits all have meaningful zeroes. - Give a PATCH endpoint three-state semantics: key absent means leave unchanged, key present with null means clear, key present with a value means set. A truthiness check cannot express that at all.
- Try the empty-array case: does
tags: []mean "no tag filter" or "items with no tags"? Both are defensible; only one is written down. - Turn on
strictNullChecksin a codebase that leans on truthiness and count how many of the resulting errors are real bugs rather than noise.
TRAIN IT
Fix a filter that ignores zero
A boundary workout: decide what absent, empty and zero each mean, then make the tests that cover all three pass.
Open the workout