Happy-Path-Only Code.
AI assistants tend to generate code that handles only the successful, well-formed case — skipping input validation, error handling, null/empty checks, and edge cases — so the code works in the demo and breaks in production.
##Signs and Symptoms
A reviewer spots happy-path-only code when every line assumes the previous one succeeded: network calls aren't checked for non-2xx status, JSON.parse/await res.json() is never wrapped, nullable fields are dereferenced directly, arrays are indexed without checking length, and external input flows straight into the logic with no validation. There is usually exactly one return path and no throw, no guard clauses, and no catch (or a catch that's empty or just console.log).
// AI-generated: only the success scenario exists
async function getUserCity(userId) {
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();
return user.address.city.toUpperCase();
}
Failure modes silently absent: res.ok is never checked (404/500 returns an error body that .json() may reject on), user could be {}, address could be null, city could be undefined → Cannot read properties of undefined. The function "works" against a happy stub and throws against reality.
Tell-tale signs in a diff:
- A new
asyncfunction with notry/catchand no.catch()on the promise. - Direct property chains (
a.b.c.d) on data that crossed a trust/IO boundary. - Unchecked
arr[0],find(...)!,ascasts, or non-null assertions (!) standing in for real handling. // TODO: handle errorsor a barecatch (e) {}left as a placeholder.- The PR description says "handles X" but only the X-succeeds branch is implemented.
##Reasons for the Problem
Why models produce it
- Next-token probability favors the canonical flow. The most probable continuation after
const user = await res.json()isreturn user.something— not a status check. Error handling is high-variance boilerplate that varies per codebase, so it is statistically "surprising" and gets dropped. - Training data is happy-path-biased. Tutorials, README snippets, blog posts, and accepted Stack Overflow answers strip out validation and error handling for brevity ("error handling omitted for clarity"). The model learned from text that deliberately removed the very code you want.
- Sycophancy / reward-for-looking-clean. RLHF-tuned assistants are rewarded for concise, directly-responsive answers. Defensive code looks like noise, so the model optimizes for the tidy snippet that appears to answer the prompt.
- No repo context. The model doesn't know you have an
AppErrortype, aResult<T>wrapper, azodschema, or a logging convention, so it can't reuse them — and defaults to "nothing" rather than guessing your conventions. - Documented behavioral tendencies. OX Security's analysis of 300+ repos names Avoidance of Refactors and By-the-Book Fixation among its top AI anti-patterns — the model emits functional code for the immediate prompt and never hardens it. arXiv 2509.20491 catalogs AI-specific smells around silent failures; arXiv 2510.03029 finds elevated implementation smells like Empty Catch Block in LLM output.
Why it hurts
- Correctness: crashes on
null, empty collections, timeouts, and non-200 responses — exactly the inputs that don't appear in a quick manual test. - Security: the happy path implicitly trusts its input. Skipped validation at boundaries is how injection, path traversal, and prototype-pollution bugs enter. OX's report frames this as code that is "insecure by dumbness," shipped fast without judgment.
- Review load & debugging: in the 2025 Stack Overflow Developer Survey, 66% of developers cite "AI solutions that are almost right, but not quite" as their top frustration and 45% say debugging AI-generated code is more time-consuming. Happy-path code is the archetype of "almost right" — it reads fine and fails at runtime.
- Tech-debt accrual: because the model doesn't refactor or reuse existing error utilities, each happy-path function is a fresh single-use blob. GitClear's 2025 data shows the broader pattern — copy/pasted lines rose from 8.3% (2021) to 12.3% (2024), 5+-line duplicate blocks grew ~8x in 2024, while refactoring (moved lines) fell from ~25% to under 10%. Missing error handling gets bolted on later, duplicated per call site, never centralized.
##Treatment
Review & prompting tactics
- Make the model enumerate failure modes first. Prompt: "Before writing code, list the failure modes for this function (bad input, network error, non-200, empty/malformed response, missing fields, concurrency). Then implement handling for each." Forcing the enumeration step counteracts the next-token tendency to skip them.
- Point at the conventions to reuse. "Use our existing
AppError/Resulttype fromlib/errors.tsand theloggerfromlib/log.ts; validate the response with thezodschema inschemas/user.ts." This converts "no handling" into reuse instead of a bespoke blob (avoids Duplicate Code). - Require the gates to run. "Run
eslintandtsc --noEmitand fix every warning, including@typescript-eslint/no-floating-promises." Type-checking withstrictNullChecksturns silent null derefs into compile errors the model must address. - Demand the negative tests. Ask for unit tests covering the empty/null/error inputs, not just the happy case — the absence of those tests is itself the smell.
The refactor
Add boundary validation and guard clauses, and centralize handling. Named moves: Introduce Guard Clause (early throw on invalid state), Introduce Assertion / boundary validation (parse-don't-validate at IO edges), and Introduce Special Case / Null Object (?? "UNKNOWN" instead of crashing).
// after: failure modes are first-class
async function getUserCity(userId: string): Promise<string> {
if (!userId) throw new InvalidArgumentError("userId required"); // guard clause
const res = await fetch(`/api/users/${encodeURIComponent(userId)}`);
if (!res.ok) throw new ApiError(`user fetch failed: ${res.status}`);
const user = UserSchema.parse(await res.json()); // validate at the boundary
return user.address?.city?.toUpperCase() ?? "UNKNOWN"; // special-case the gap
}
If the same try/validate/log pattern starts repeating across call sites, Extract Function it into a shared fetchJson<T>(url, schema) helper so error handling lives in one place rather than being copy-pasted (the GitClear duplication trap).
##Detected by
- typescript-eslint @typescript-eslint/no-floating-promises — Flags promises whose rejection path is never handled (no await/catch) — a common happy-path symptom for async calls.
- ESLint no-empty — Flags empty blocks including empty catch blocks — the placeholder 'error handling' the model leaves behind.
- SonarSource (JS/TS) javascript:S2486 — Exceptions should not be ignored — flags caught-but-swallowed errors, the degenerate cousin of no handling at all.