Pattern · last reviewed 2026-07-26
Fail empty: absence must not render as a value
Every consumer of optional data needs an explicit branch for "the data did not arrive", and that branch has to clear — not leave the last thing that was there, and not fall back to something plausible. The defect this prevents has one mechanism and many costumes: the degraded path is never written, so it falls through and inherits whatever the happy path would have produced. An absence then becomes indistinguishable from a result, which is worse than an error, because an error is legible and a stale value is not.
Two shapes are worth learning as one pattern. A view populated by a consumer that overwrites but never clears will show its own placeholder as though it were the visitor's data. A threshold compared against a number that can arrive malformed will let the malformed case straight through, because the comparison is false in both directions. Both are in the code below, dated, with the fix that shipped.
Context & problem
Interfaces are usually built populated. A static page or a mockup is filled with realistic sample content, because sample content is what makes it reviewable — an empty table proves nothing to the person approving the design. Later a consumer is wired in to replace that content with real data. The consumer gets written for the case it was commissioned for: data arrives, data is rendered. The case where data does not arrive is not a case anybody was asked about, and it is not visible in review either, because the reviewer is looking at the populated state. So the branch is missing, and the sample content stays on screen — now labelled as the visitor's.
Agent-assisted building sharpens this considerably. Scaffolding a plausible, populated page is one of the things a capable model is genuinely excellent at, and the resulting artifact is convincing precisely because the sample data is convincing. The wiring pass that follows is scoped to make the real data appear. Nothing in either pass is wrong, and nothing in either pass owns the empty state. The failure is a seam between two correct pieces of work, which is why it survives review: each half is fine on its own terms.
The numeric version of the same seam is quieter still. A filter that discards low-scoring results assumes the score is a number. When a provider changes a response shape, or a field goes missing, or a division produces NaN, the comparison that implements the filter stops filtering — and it stops filtering in exactly the situation the filter exists to protect against. Nothing throws. The code path looks identical. You get a full set of results that all cleared a floor none of them was measured against.
Forces
- A default is easier to design than an absence. Every empty state is extra work with no demo value, so it loses to the populated state in every review that is under time pressure. This is not a discipline problem; it is what the incentives produce unless the empty state is somebody's explicit deliverable.
- "We have nothing" and "we failed to fetch" are different facts with the same rendering. Collapsing them is convenient and it removes your ability to distinguish a quiet product from a broken one, in the UI and in the logs.
- Hiding is not clearing. A falsehood behind
display:noneis one CSS change, one debugging session, or one screen-reader traversal away from being read. It is also invisible to the reviewer who would have caught it as text. - Fail-open predicates read as safe code.
x < floorand!(x >= floor)look like the same test and differ on exactly one input class. The version that fails open is the one people write first, because it is the one that reads like the sentence. - Fail-empty is a direction, not a virtue. For a guard whose absent case means "allow", rendering or returning nothing is the dangerous side. You have to name the safe direction per guard rather than apply one reflex, and the whole value of this pattern is in doing that naming deliberately.
The pattern
Three rules, small enough to apply at the moment of writing rather than at review:
- Write the else. Any consumer that can populate a surface gets an explicit no-data branch, and that branch clears the surface. A function with two success paths and no failure path is the shape to look for — it will read as complete because both of its paths are correct.
- Ship the empty state as the default state. The markup's resting content is what a visitor sees when the consumer never runs — a network failure, a JavaScript error, a blocked script. Make that state truthful by construction, which usually means the placeholder is deleted, not hidden. Keep the populated version somewhere nothing serves as live product if you still need it for review.
- Negate the predicate; do not complement it by hand. Write the condition you want to be true (
score >= floor) and negate it (!(score >= floor)), rather than writing the opposite you inferred (score < floor). With floating point these are not the same test: relational comparisons involvingNaNare always false, so the hand-written complement keeps the value it was supposed to drop.
Reference implementation notes
The render path: delete the placeholder, do not hide it
This platform shipped the textbook instance and fixed it on 2026-07-26. A learner's module page carries a banner showing where a diagnostic placed them. The consumer had two write paths — a localStorage copy for the offline case and a server response that supersedes it — and neither had an else. The page's markup, meanwhile, shipped a hard-coded sample placement: a specific role, a specific track, a specific module. So every signed-in learner who had never taken the diagnostic read a stranger's result as their own, with a label above it saying it was theirs.
The obvious fix was wrong twice over, and both halves are worth stealing. First, toggling the hidden attribute would not have hidden it: hidden is implemented as a user-agent stylesheet rule, and the banner's own class sets display:flex, so the author rule wins on cascade origin and the element stays visible while the attribute claims otherwise. Second — and this is the part that generalises past the DOM — hiding the sample copy would have left the false text in the shipped document. The fix deleted it, so the resting state of the markup is the honest one and a JavaScript failure now renders nothing rather than a falsehood.
<!-- public/module.html - the default state IS the empty state, by construction --> <div class="placement" id="placement" style="display:none"> <div class="pl-label">Your placement (from diagnostic)</div> <!-- ships empty: the sample placement that used to live here was deleted --> <div class="pl-body" id="placementBody"></div> </div>
// public/assets/module.js - the branch that was missing, in the consumer function reveal(text) { // not `show` - that name is the stepper's navigator if (!text) return; // no placement: write nothing, reveal nothing host.innerHTML = esc(text); banner.style.display = ""; // `hidden` would lose to .placement{display:flex} }
Note what the guard is not doing: it does not clear the body, because there is nothing to clear once the placeholder is gone. That is the point of rule 2 — with a truthful default state, the no-data branch collapses into "do nothing", which is the cheapest branch there is and the one least likely to rot.
The numeric floor: negate the predicate, do not complement it
The same shape, in retrieval. Two search paths here discard results below a similarity floor, and both were first written as score < floor. That predicate silently stops filtering the moment a score is not a number — a missing field, a provider shape change, a division by zero — and it stops filtering in exactly the circumstances that make the floor load-bearing. Both are now written as the negation of the condition that must hold.
The sharpest instance in this repo is not in retrieval at all, and it was written correctly first, which is why the retrieval fix was transcribed from it rather than reasoned out again. The per-user coach spend cap skips recording a non-positive turn. Written as usd <= 0 that test is false for NaN, so a NaN cost would be added to the running total, the total would become NaN permanently, and every later "have we hit the cap?" comparison would be false — an unbounded-spend bug produced by the guard that exists to bound spend.
// src/lib/spendCap.ts - the cap's own last-line guard // `!(usd > 0)`, not `usd <= 0`: NaN <= 0 is false, so a NaN would be recorded, // spent_usd becomes NaN, and every future `spent_usd >= cap` is false. if (!(usd > 0)) return; // src/lib/rag.ts and src/lib/guideSearch.ts - same instinct, copied not re-derived if (!(m.score >= MIN_VECTOR_SCORE)) continue;
There is a process lesson underneath the code one. The correct form and its reasoning were already written down in a sibling file, so the fix was a transcription, and transcriptions are right the first time in a way that fresh derivations are not. When you are about to author a correction, it is worth thirty seconds of grepping for the same problem solved elsewhere in your own repo — if one instance is already right, that is your wording, and if two disagree you have found a second defect rather than a style question.
Trade-offs
- You now owe an empty state, and honest empty surfaces look unfinished. That cost is real and it is the reason the defect exists. The mitigation is to design the empty state as content — say what is missing and what produces it — rather than to render a void.
- Deleting the placeholder costs you the reviewable mockup. Keep the populated version somewhere it cannot be mistaken for the reader's own data — a design reference, a test fixture, a screenshot. The line is not public versus private: it is whether anything on the page claims the content belongs to the reader. This repo's static design reference still carries the same block with no consumer behind it, and whether a label reading "your placement" is defensible even there is an open question on its own ticket. The live defect only appeared when that markup was carried onto a surface that did have a consumer.
- Absence and error are different states, and this pattern only fixes one. Rendering nothing is right for "there is no data"; a failed fetch may still deserve to be told to the user. Collapsing all three into empty is a smaller lie than the placeholder, not the truth.
- The negated predicate reads worse.
!(score >= floor)is uglier thanscore < floorand every reviewer will want to simplify it. The cost is one comment naming the input class it protects against — without that comment, the next cleanup pass reverts it. - A last-line guard duplicates a boundary check you may already have. Upstream clamping is the right primary control; the guard is cheap insurance that costs one branch and survives the day the upstream clamp is refactored by someone who did not know it was load-bearing.
When not to use this
Do not clear a value that is genuinely known. A zero balance, an empty cart, a score of nought are facts, and replacing a fact with a blank invents an absence that does not exist — the mirror image of this defect and just as misleading. The test is whether the rendered content is derived from the missing data or merely adjacent to it: a placeholder is content nobody computed, and that is what has to go.
Do not reach for it where the false state is obviously false. A skeleton loader is not a claim, because nothing about it reads as content; neither is an explicit "not yet measured". Those are already empty states with better manners, and swapping them for a blank is a downgrade.
And do not apply the predicate rule mechanically to guards whose safe direction is the other way. If a check's job is to admit and the absent case should be admitted, then failing closed on a malformed input is the outage, not the fix. Name the safe direction for each guard in a comment beside it. The rule is "the unexpected value falls on the side you chose deliberately", not "the unexpected value is always dropped" — and a guard whose safe direction nobody wrote down will be flipped by the first person to tidy it.
As-built evidence
Both instances above are this platform's own, dated, and both were found by a review pass rather than by a user. The placement banner was carrying a hard-coded sample result to every signed-in learner without a diagnostic; the fix landed the same day, deleted the sample copy rather than hiding it, and named the display:flex trap in a comment above the consumer so the next editor does not re-discover it. The retrieval floors were rewritten in the same week as part of a change that closed a separate gap on the same code, and the spend-cap guard they were copied from predates both.
The part worth reporting is what happened next, because a pattern that relies on remembering it has not been applied. The empty state is now pinned by a text-level assertion in the repo's gate test suite: the module page must ship the banner with display:none and the body element empty, reported as a pair so a regression names which half broke. It was proven red against both realistic regressions before it was kept — sample copy pasted back into the body, and the inline display:none stripped — and it carries a vacuity guard asserting its own anchor still exists, so it cannot silently stop testing. Pasting the sample copy back in is exactly how the original defect shipped, which makes this a pinned regression rather than a hypothetical one.
The class recurs one level up, outside the UI entirely, and that instance is written up separately: a gate in this repo explicitly guards a list it depends on being absent, with a comment calling a silent stop the exact failure it exists to catch — and it had no guard for that list being emptied. A refactor emptied it, the extraction still matched, the routed set fell from 21 names to 15, and the gate printed ok. Same mechanism, different costume: the degraded case fell through to the output of the healthy one. That story, and two other greens that were not evidence, are in validating the validator.
Changelog
- 2026-07-26 — Initial publication. The
hiddencascade behaviour and theNaNcomparison rule are verified against the HTML Standard and MDN respectively; the as-built accounts are dated records from this repo's own ticket ledger, gate tests, and shipped source.
- The
hiddenattribute losing to an authordisplayrule: the HTML Standard's rendering section gives it as a user-agent stylesheet expectation —[hidden]:not([hidden=until-found i]):not(embed) { display: none; }— and states that the rules in those subsections are "expected to be used as part of the user-agent level style sheet defaults". Author styles therefore win on cascade origin. HTML Standard, Hidden elements, checked 2026-07-26. - Relational comparisons with
NaN: "WhenNaNis one of the operands of any relational comparison (>,<,>=,<=), the result is alwaysfalse." MDN,NaN, checked 2026-07-26. - The placement-banner defect, the deleted sample copy, and the
.placement{display:flex}trap: this repo's own ticket ledger entry dated 2026-07-26, the shipped markup inpublic/module.html, andapplyPlacement()inpublic/assets/module.js. The trap itself is recorded in this repo's front-end notes forpublic/. - The pinned empty state (banner
display:noneplus empty body, asserted as a pair, proven red on both regressions, with a vacuity guard):test/gates.test.mjs, added 2026-07-26. - The negated floors and the guard they were transcribed from:
toGroundings()insrc/lib/rag.ts, the Vectorize branch ofsearchGuides()insrc/lib/guideSearch.ts, andrecordSpend()insrc/lib/spendCap.ts— all first-party source in this repo, quoted as shipped. - The absent-versus-emptied gate (routed set 21 → 15 while the gate printed
ok):parseRoutedNames()inscripts/lint-monday-artifacts.mjsagainst the list in this repo's huddle workflow script, recorded in the dated post-mortem for 2026-07-26. - Practice claims ("this platform shipped...", "found by a review pass...") are from operating the aiArch repo itself, not from a vendor source.
The incidents and quoted lines are fixed to the dates given; the files they live in are under active change. Re-read the source before relying on a snippet. Corrections: hello@aiarch.dev.
Learn to build the degraded paths, not just the happy ones.
aiArch teaches evaluation, guardrails, and production agent architecture by building — on a platform that publishes the week its own placeholder shipped as somebody's data. The build is the curriculum.
See how aiArch helps senior engineers become AI-native, or compare Professional Membership pricing.
Free sample — no signup · every claim cited · full curriculum is waitlist-only