Skip to content
0degrees.ai
Prompt Engineering

Prompting AI for Defensive Code: Getting the Unhappy Path Right

AI coding assistants default to the happy path. Here's how to systematically prompt for input validation, error boundaries, and the failures that break production.

0degrees Team 8 min read

AI coding assistants are optimistic by default. Give one a task — parse a user ID from a URL, call an external API, read a file — and it will write you code that handles the case where everything goes right. The happy path will be clean and correct. The error handling will be an afterthought, if it exists at all.

This isn’t a bug in the model. It’s a reflection of what most training examples look like: complete, working code that demonstrates a capability. But production code spends most of its defensive budget on cases that don’t appear in tutorials — the null input, the expired token, the malformed response from the third-party service that started returning HTML instead of JSON at 3 AM.

If you want AI to write production-quality code, you have to specifically ask for it.

Why AI defaults to the happy path

The model’s job is to complete the task you described. If your description is “write a function that fetches a user by ID”, the description of success is a function that fetches a user by ID — which it produces. The failures are unstated, so they don’t get addressed.

This is different from hallucination. The model isn’t wrong about what it generated; it just solved a narrower problem than you specified. The issue is specification, not correctness.

The fix is to make the unhappy path part of the specification.

State the error contract upfront

The single most effective change is to describe your error-handling requirements before you describe the feature. Not after, as a correction — before, as part of the spec.

Compare:

Write a function that fetches a user from our API by ID.

vs.

Write a function that fetches a user from our API by ID.

Error contract:
- If the ID is not a positive integer, throw InvalidArgumentError immediately — no network call
- If the API returns 404, return null
- If the API returns 5xx, throw a ServiceUnavailableError with the status code
- If the network request times out (use 5s timeout), throw a TimeoutError
- Never let a raw fetch rejection or JSON parse error escape — wrap and rethrow

The second version produces code that handles what actually happens in production. The first produces code that handles the interview-question version of the problem.

The key move is describing what errors propagate out — the contract at the function boundary — rather than just noting that errors can exist.

// What you get from the second prompt:
async function fetchUser(id: unknown): Promise<User | null> {
  if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
    throw new InvalidArgumentError(
      `User ID must be a positive integer, got: ${String(id)}`
    );
  }

  let response: Response;
  try {
    response = await fetch(`/api/users/${id}`, {
      signal: AbortSignal.timeout(5_000),
    });
  } catch (err) {
    if (err instanceof DOMException && err.name === "TimeoutError") {
      throw new TimeoutError("User fetch timed out after 5s");
    }
    throw new ServiceUnavailableError(`Network error: ${String(err)}`);
  }

  if (response.status === 404) return null;
  if (!response.ok) {
    throw new ServiceUnavailableError(`API returned ${response.status}`);
  }

  try {
    return (await response.json()) as User;
  } catch {
    throw new ServiceUnavailableError("API returned non-JSON response");
  }
}

The failure inventory prompt

When you’re not sure what the failure modes are — or you want the model to surface ones you haven’t thought of — use a failure inventory step before writing the implementation:

Before we write the implementation, list every way this operation can fail:

  "Parse the authorization header from an incoming HTTP request
   and validate it as a JWT signed with our secret."

Include: invalid inputs, malformed data, expired tokens, network
dependencies, race conditions, and any assumptions the code will make
that could prove wrong.

This is a separate step from the implementation. Use it to discover the failure surface, then fold the relevant items into your implementation spec. The model is good at generating these lists — it’s the same pattern-matching that makes it good at writing code, applied to failure modes instead.

A typical output for the JWT example surfaces: missing header, wrong format (not "Bearer ..."), base64 decode failure, JSON parse failure, missing required claims, wrong signing algorithm, clock skew on expiry, revoked tokens (if you have a revocation list). Some you’ll address in the code; others you’ll mark as explicit non-requirements. Both outcomes are useful before you write a line of implementation.

Add explicit unhappy-path test cases to the spec

When you’re generating code you plan to test, specify the test cases alongside the implementation request:

Write a `parseAmount` function that converts a user-facing currency string
to integer cents (e.g. "$12.34" → 1234).

The implementation must pass these cases:
- "$0.00"     → 0
- "$12.34"    → 1234
- "$1,234.56" → 123456   (commas allowed)
- "$0.1"      → 10       (single decimal digit)
- ""          → throws ParseError
- "abc"       → throws ParseError
- "$-5.00"    → throws ParseError (negative amounts rejected)
- "$12.345"   → throws ParseError (more than 2 decimal places)
- null        → throws ParseError
- undefined   → throws ParseError

When the AI sees concrete failing-case expectations in the spec, it writes code to make them pass rather than guessing at which edge cases matter. This is not the same as asking the model to generate test cases — you’re specifying what the function must do, using test-case format as a precise language for behavior. The generated code either satisfies the spec or it doesn’t, and you can verify that mechanically.

Push validation to the boundary

A common pattern in AI-generated code is validation logic scattered throughout the call chain: some checks in the route handler, some in the service layer, some in the database layer. When something invalid slips through, it’s caught late — or not at all.

Prompt for explicit boundary validation:

Add validation to this function that runs before any business logic.
All validation must happen at the top of the function, before any I/O
or state mutation. If any input is invalid, throw immediately with a
clear message explaining what was wrong and what was expected.

Or, in a TypeScript/Zod project:

Use Zod to validate this function's inputs at runtime. Parse the inputs
with a schema at the very start of the function, before any other logic.
If parsing fails, rethrow the ZodError as a ValidationError with the
formatted issue list.

The constraint “before any I/O or state mutation” prevents the common pattern where the model validates some fields at the top but defers others deeper in the call chain, where failures are harder to trace and the partial state mutation has already happened.

Ask for explicit null and undefined handling

AI-generated TypeScript often compiles cleanly under strict mode but has logical null-handling gaps: an optional field accessed without a guard, a .find() result used without checking for undefined, an array that might be empty indexed directly. A review prompt catches these:

Review this function for unguarded nulls and undefined accesses.
For every access that could be null or undefined, either add a guard
that throws a descriptive error, or explain in a comment why the value
is guaranteed to be present.

Do not use optional chaining (?.) as a silent null-propagation mechanism.
I want explicit early returns or throws so failures are visible in the
stack trace.

The model is capable of this audit — it’s the kind of reasoning it does well when asked for it directly. The default is to omit it.

The second pass is the part people skip. Running the review prompt after the implementation prompt catches a class of bugs that would otherwise show up in production as a TypeError: Cannot read properties of undefined with a useless stack trace. The prompts together take about thirty seconds; diagnosing the same issue in a minified production build takes considerably longer.

A practical workflow

Put these pieces together into a consistent pattern:

  1. Start with the failure inventory. Ask the model to list all the ways the operation can fail before writing any code. Keep the output as notes.

  2. Write the error contract. From the inventory, decide what your function throws, what it returns for expected missing cases (null vs. throw), and what it never lets escape as a raw error.

  3. Prompt for the implementation with the full spec. Include the error contract, explicit test cases for unhappy paths, and the constraint that validation happens at the boundary before any I/O.

  4. Run a null-handling review pass. After the implementation, run a separate review prompt focused only on null and undefined. This catches what the initial generation missed.

Each step is cheap. The failure inventory prompt takes ten seconds. The review pass catches a class of bugs that production monitoring would find instead.

The underlying principle

The model will write what you specify. If your specification describes a function that works when everything is fine, you’ll get a function that works when everything is fine. If your specification describes what the function does when things go wrong, you’ll get that instead.

The shift is from describing a capability to describing a contract. A capability — “fetch a user by ID” — is satisfied when the model demonstrates the mechanism. A contract — “fetch a user by ID, returning null on 404, throwing ServiceUnavailableError on 5xx, throwing TimeoutError after 5 seconds, and never letting a raw error escape” — is satisfied only when the implementation handles the full failure surface.

That extra precision costs thirty seconds in the prompt. It saves considerably more when the first production incident against this code becomes somebody else’s problem.

For a related technique applied to test generation — using AI to produce the full spectrum of cases rather than just the happy-path ones — see Writing Tests with AI. And for the discipline of reviewing what the model ships before you merge it, Evaluating AI-Generated Code covers the review workflow end to end.

[ Related ]

Keep reading