ConstructiCat Logo
CodeBust.
Browse section ▾

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 a string.
  • Duplicate or subsumed guardsif (!user) return immediately followed by if (user === null || user === undefined) return.
  • Catch-all try/catch around 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 found AI routinely adds "logic for imaginary edge cases."
  • 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/catch is low-risk for the next-token objective and reads as conscientious, so it is over-sampled. A reviewer at scale noted repeated if (array && array.length > 0) chains are "a sign the model isn't fully confident in the code flow" — the guard is a hedge against the model's own uncertainty.
  • 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. arXiv 2509.20491 (AI-Specific Code Smells) shows LLMs "struggle with code smells that are flow-dependent or value-sensitive" — when they can't reason about flow, they default to guarding everything locally.
  • Security-prompt cargo-culting. Research on secure-code prompting found that when asked to "make it secure," LLMs "add try-catch blocks as a standalone security measure without other security enhancements... typically when they cannot identify specific vulnerabilities." 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 LLM-Python inefficiencies (arXiv 2503.06327) catalogs redundant input validation, over-defensive error handling, and unnecessary guard conditions, concluding these "defensive patterns add complexity without proportional safety benefits." 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/catch that 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 found refactoring's share of changed lines fell from 25% (2021) to under 10% (2024) while copy/pasted lines rose to 12.3% and duplicate blocks grew ~8x. Defensive boilerplate is exactly the kind of code that gets cloned instead of extracted, and cloned blocks correlate with 15–50% more defects.
  • 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: "user is non-nullable and already validated by the caller — do not re-check it." Lean on types: with strictNullChecks on, @typescript-eslint/no-unnecessary-condition will 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: Remove Dead Code, Consolidate Conditional Expression, Replace Nested Conditional with Guard Clauses, and (for input that does need checking) Introduce Assertion / validate once at the boundary instead of repeatedly. Where the same guard was copy-pasted, Extract Function.

// 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