Defensive Overkill.
AI assistants hedge against failures that can't happen, wrapping already-safe code in redundant null checks, dead guard clauses, and catch-all try/except blocks that add complexity without adding safety.
##Signs and Symptoms
A reviewer spots Defensive Overkill when a function spends more lines guarding against impossible states than doing actual work. Tell-tale signs:
- Guards for conditions the type system already guarantees — null/undefined checks on a non-nullable typed parameter,
typeof x === "string"on astring. - Duplicate or subsumed guards —
if (!user) returnimmediately followed byif (user === null || user === undefined) return. - Catch-all
try/catcharound code that cannot throw, often swallowing the error or just re-throwing it ("just in case"). - Logic for "phantom" edge cases — branches that handle inputs no caller can produce. OX Security's Phantom Bugs pattern — AI that "over-engineers for improbable edge cases" — appeared in 20–30% of the code it analyzed (OX report).
- Defensive blocks copy-pasted rather than extracted, so the same guard appears in five places.
// User is a NON-nullable type: { profile: { name: string } }
function getDisplayName(user: User): string {
if (!user) return "Unknown"; // dead: user is non-nullable
if (user === null || user === undefined) return "?"; // duplicate, also dead
try {
if (user.profile && typeof user.profile.name === "string") { // type already guarantees this
const name = user.profile.name;
if (name.length > 0) {
return name.trim() !== "" ? name.trim() : "Unknown";
}
}
return "Unknown";
} catch (e) {
console.error("could not get name", e); // this block can't throw
return "Unknown";
}
}
Nine lines of hedging wrap one line of intent. The contradiction noted by reviewers is that the same model often omits the one check that matters (e.g. validating untrusted external input) while over-guarding internal calls — defensiveness scattered by vibe, not by threat model.
##Reasons for the Problem
Why models produce it
- Next-token risk-aversion / RLHF "helpfulness." Models are tuned to look thorough and avoid being "wrong." Emitting an extra guard or
try/catchis low-risk for the next-token objective and reads as conscientious, so it is over-sampled. Repeatedif (array && array.length > 0)chains read like a hedge against the model's own uncertainty about the code flow. - No whole-repo dataflow context. The model can't see that a caller already validated the argument, or that a TypeScript type makes a branch unreachable. When it can't reason about data flow beyond the current file, it defaults to guarding everything locally.
- Security-prompt cargo-culting. Asked to "make it secure" without a concrete threat to address, a model can fall back on wrapping code in
try/catchas if that were a security measure in itself. Defensiveness substitutes for understanding. - Training-data bias toward verbose, tutorial-style code that demonstrates every check for pedagogy, plus a refusal to refactor: OX Security found avoidance of refactors in 80–90% of AI code and over-specification in 80–90% — the model adds, it rarely removes.
Why it hurts
- Maintainability & review load. A taxonomy of inefficiencies in LLM-generated Python code includes Unnecessary Conditional Block and redundant or unnecessary steps (arXiv 2503.06327) — exactly what dead guards are. Reviewers must read every dead branch to confirm it is dead.
- Correctness, not just clutter. Catch-all blocks that swallow or generically handle errors hide real failures; a
try/catchthat returns a default turns a bug into silent wrong behavior. The guards also create dead branches that tests dutifully cover, inflating coverage with meaningless tests (another OX finding). - Tech-debt compounding. GitClear's 2025 analysis of 211 million changed lines (2020–2024) found that refactoring-associated ("moved") lines fell from 25% of changed lines in 2021 to under 10% in 2024, while copy/pasted lines rose from 8.3% to 12.3% over the same period; 2024 was the first year copy/pasted lines exceeded moved lines, and code blocks with five or more duplicated lines grew roughly eightfold during 2024 (GitClear 2025). Defensive boilerplate is exactly the kind of code that gets cloned instead of extracted, so every copy has to be read, tested and eventually fixed separately.
- Higher cognitive complexity per function makes the real logic harder to find, slowing every future change.
##Treatment
Review & prompting tactics
- Make the contract explicit so guards become provably unnecessary. Tell the model: "
useris non-nullable and already validated by the caller — do not re-check it." Lean on types: withstrictNullCheckson,@typescript-eslint/no-unnecessary-conditionwill flag dead guards for you. - Prompt for proportional defense, not blanket defense. Ask: "Only handle errors you can describe a concrete trigger for. Validate untrusted/external input at the boundary; trust internal calls." This separates real input validation (keep it) from phantom edge cases (delete them).
- Require the model to run the linter/type-checker and remove what it flags before returning code — close the loop the way agent practitioners recommend (linters, type checkers, tests as automated feedback signals).
- Ask it to delete, not just add: "Refactor for the minimum code that satisfies the spec; remove unreachable branches and catch blocks that can't fire." This counters the documented "avoidance of refactors" bias.
The refactor
Name the classic moves: delete the Dead Code, Consolidate Conditional Expression, Replace Nested Conditional with Guard Clauses, and (for input that does need checking) Introduce Assertion — or validate once at the boundary — instead of repeating the check. Where the same guard was copy-pasted, Extract Method.
// AFTER — type guarantees non-null; validate once, no phantom catch
function getDisplayName(user: User): string {
const name = user.profile.name.trim();
return name || "Unknown";
}
If a value genuinely is untrusted, validate it once at the edge and let the rest of the code trust the now-narrowed type:
// boundary: parse/validate untrusted input a single time
const user = UserSchema.parse(rawInput); // throws on bad data, here, on purpose
// ...everything downstream takes a validated `User` and needs no re-guarding
Rule of thumb for reviewers: every guard and every catch must answer "what concrete caller or input triggers this?" If the answer is "nothing — just in case," delete it.
##Detected by
- typescript-eslint @typescript-eslint/no-unnecessary-condition — no-unnecessary-condition
- ESLint no-useless-catch — no-useless-catch
- SonarSource (SonarQube / eslint-plugin-sonarjs) RSPEC-2589 — no-gratuitous-expressions — Boolean expressions should not be gratuitous (always-true/false conditions)
- SonarSource (SonarQube / eslint-plugin-sonarjs) RSPEC-3776 — cognitive-complexity — Cognitive Complexity (nested defensive guards inflate it; proxy detector)