Technical
Timothy Yang

The bug is almost never where the error message points

A stack trace tells you where your program died, not where it went wrong — and the guard clause your agent adds to make the 500 disappear usually deletes the only signal you had. Here's how to trace a bad value back to its source instead.


Your checkout endpoint throws TypeError: Cannot read properties of undefined (reading 'weightGrams') at shipping.ts:47. You paste it into Claude Code, it adds a guard, the 500 goes away, and two weeks later someone notices you've been undercharging shipping on every order that included a free gift.

That's the whole failure mode. The stack trace told you where your program died, and you treated it as where your program went wrong. Those are almost never the same place, and the gap between them is where most of your debugging time actually lives.

The error is the second event, not the first

A runtime error fires at the moment a bad value is finally used in a way the runtime can't tolerate. But the value became bad earlier — sometimes microseconds earlier, sometimes three modules and one deploy earlier. Everything in between happily passed it along, because most code has no opinion about the data flowing through it.

Here's the real trace from the bug above:

TypeError: Cannot read properties of undefined (reading 'weightGrams')
    at estimateShipping (src/lib/shipping.ts:47:31)
    at buildCheckout (src/lib/checkout.ts:112:20)
    at POST (src/app/api/checkout/route.ts:23:24)

And line 47:

for (const line of cart.lines) {
  const item = itemsById[line.sku];
  grams += item.weightGrams * line.qty;
}

Nothing on line 47 is wrong. itemsById[line.sku] returning undefined is a correct response to a lookup for a key that isn't there. The code is faithfully reporting that someone handed it a cart line whose SKU has no matching item. Line 47 is the messenger.

The bug was 60 lines away, in the function that built itemsById:

// src/lib/orders.ts
const items = raw.lineItems
  .filter(li => li.priceCents)   // <- the actual bug
  .map(li => [li.sku, li]);

A truthy check on a number. The free gift tote has priceCents: 0, so it fails the filter and never makes it into the map. Meanwhile cart.lines comes from a different source and keeps every line. Two collections derived from the same order, one of them quietly filtered. The error surfaces in the shipping calculator because that's the first place anything actually dereferences the result.

The fix that isn't a fix

Point an agent at that stack trace with no other context and you'll usually get one of these:

const item = itemsById[line.sku];
if (!item) continue;              // option A
grams += (item?.weightGrams ?? 0) * line.qty;  // option B

Both make the error message disappear. Both are worse than the crash. Option A silently drops the gift's weight from every shipping quote. Option B does the same thing with more punctuation. The 500 error was the only mechanism telling you your data model had a hole in it, and you just deleted it.

This isn't an AI-specific failing, to be fair — humans have been slapping null checks on stack frames since null checks existed. But agents make it faster and more confident, and they do it at a scale where nobody reads the diff carefully. The Stack Overflow team has written about the rise in incidents that came alongside heavier agent use in 2025, and logic and correctness issues are the category they call out as the dangerous one — precisely because they don't announce themselves.

There's a simple tell for whether you've found the bug or just muffled it. If your fix is a guard clause, you probably haven't found the bug. A real fix usually lands at the place the bad value was created, or the place an invariant was violated. A guard lands at the place the bad value was noticed. Different postcodes.

Ask "where did this value come from", not "fix this error"

The prompt you give the agent decides which of those you get. "Fix this error, here's the stack trace" is an instruction to make the stack trace stop. So don't say that.

What works better, in practice:

Don't fix anything yet.

`itemsById` has no entry for sku "GIFT-TOTE" at shipping.ts:47, but
cart.lines does contain that sku. Something upstream dropped it.

Trace backwards: find every place itemsById, or the array it's built
from, is constructed, filtered, mapped or reassigned between the DB
query and this call site. For each transform, tell me what it can drop.

Then give me the three most likely places the item disappears, ranked,
and for each one the single log line or assertion that would prove it.

Three things are doing work there. First, "don't fix anything yet" — otherwise the agent will start editing while it's still reasoning, and you'll be reviewing a diff instead of a hypothesis. Second, you've handed it the values, not just the error: the sku exists here and not there. That's a far tighter search than "something is undefined". Third, asking for a ranked list with a falsifying test per item forces it to commit to something checkable instead of narrating plausibly.

On this bug, that prompt gets you to the .filter(li => li.priceCents) line most of the time, because "what can this transform drop" is exactly the question a filter answers badly.

If you want this behaviour by default, put it in CLAUDE.md:

## Debugging
When I paste an error, do not patch the failing line first.
Identify where the bad value originated and state the invariant
that was violated. Propose the smallest assertion that would have
caught it closer to the source. Only then propose a fix.

Move the error closer to the cause

The permanent fix here isn't just != null in the filter. It's making the next version of this bug impossible to misdiagnose.

// src/lib/orders.ts
const items = raw.lineItems.map(li => [li.sku, li]);
const itemsById = Object.fromEntries(items);

const missing = cart.lines
  .map(l => l.sku)
  .filter(sku => !(sku in itemsById));

if (missing.length) {
  throw new Error(
    `Cart lines reference unknown skus: ${missing.join(", ")}`,
    { cause: { orderId: raw.id, skus: missing } }
  );
}

Now the stack trace points at orders.ts, names the SKU, and says what rule was broken. The failure moved about 60 lines and one module closer to its cause, which is the entire game. Assertions aren't there to prevent bugs — they're there to relocate them.

That cause option is ES2022 and it works everywhere you care about. Use it. throw new Error("checkout failed", { cause: err }) when you re-throw preserves the original instead of flattening it into a string, which is how you end up with a "failed to process request" error that tells you nothing. Python's equivalent is raise CheckoutError(...) from err.

And if you're on the JVM, read the trace properly: Rollbar's guide makes the point that the top exception only shows where the failure surfaced, and the last Caused by block is the one that tells you why. People skim the top three frames and stop. The information they wanted was at the bottom.

Your line numbers might also be lying

Worth ruling this out before you spend an hour reasoning about the wrong code. If you're running TypeScript through a bundler, or anything minified, the line numbers in your trace refer to build output, not your source. Node has had native source map support since v12 via --enable-source-maps, which rewrites the stack to point at your original files. If your production traces point at dist/index.js:1:48213, that flag is the cheapest debugging win available to you today.

The opposite case is worth knowing too. Python 3.11 shipped PEP 657, fine-grained error locations, which adds carets under the exact sub-expression that blew up:

Traceback (most recent call last):
  File "checkout.py", line 12, in estimate
    grams += items[sku].weight * line.qty
             ^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'weight'

That's a genuine upgrade — it tells you it was the lookup, not line.qty. But notice what it still doesn't tell you: why items[sku] is None. Better precision about the symptom. Zero information about the cause. The carets narrow your reading of line 12; they say nothing about the code that filled items.

When reasoning fails, bisect

Sometimes you can't reason backwards, because the codebase is large, or unfamiliar, or an agent wrote 4,000 lines of it last Tuesday. Then stop reasoning and binary search.

Write the smallest script that fails on the bug and succeeds otherwise:

#!/usr/bin/env bash
# scripts/repro-gift.sh
npm ci --silent || exit 125          # can't build this commit: skip it
npx vitest run tests/shipping.gift.test.ts --silent

Then:

git bisect start
git bisect bad HEAD
git bisect good v2.14.0
git bisect run ./scripts/repro-gift.sh

The exit code convention matters and trips people up: 0 means good, 1–127 means bad, and 125 specifically means "skip, can't test this commit". That 125 is what saves you when half the range doesn't build.

This is a genuinely good use of an agent, by the way — not to find the bug, but to write the repro script. "Write me a single vitest file that fails if a zero-priced line item is missing from the shipping weight calculation, no mocks beyond the DB layer" is a task models are reliably good at. Then let twenty years of Git do the search.

The objection: sometimes it really is line 47

Sure. Typos, off-by-one, a genuinely missing await, a variable you shadowed. Plenty of bugs are exactly where the trace says. If the fix is at the site and it changes the logic rather than adding a guard, you're probably fine.

The heuristic I'd actually use: can you write a failing test that doesn't touch the file in the stack trace? For the gift tote, yes — you can construct an order with a zero-priced item, call the serialiser, and assert the item survives, without ever loading shipping.ts. That test proves the bug lives somewhere else. If you genuinely can't write such a test, the trace was probably honest.

The tradeoff, stated plainly

Root-causing is slower. At 2am with customers in your Discord, shipping the guard clause is often the correct call, and anyone who tells you otherwise hasn't run anything in production.

The distinction is between deferring the fix and never knowing there was one. Deferred looks like this:

// FIXME(2026-02-14): itemsById is missing skus present in cart.lines.
// Hypothesis: the truthy filter in orders.ts:41 drops priceCents === 0.
// This guard undercounts shipping weight. Ticket: ENG-4412
if (!item) continue;

Ninety seconds of typing, and the next person gets your hypothesis instead of an archaeological dig. That's the tax you pay for the fast fix, and it's cheap.

The other thing worth doing while you're mid-thrash: if you've let an agent try six patches and the codebase is now a patchwork of speculative guards, don't keep piling on. Claude Code's checkpoint system saves state before each change, and /rewind or double-Esc gets you backAnthropic's own framing is that it lets you attempt riskier things knowing you can return. Rewind to before the guessing started, then re-approach with the value-tracing prompt instead of the error message. A clean second attempt beats a tenth patch on a dirty first one, every time.

Do this one thing this week

Open your last five commits that contain ?., ?? 0, if (!x) return, or try/catch with an empty-ish handler. For each one, ask the single question: where was that value created, and why was it wrong there?

You'll find at least one where you don't know the answer. That's a live bug wearing a costume. Trace it back, add the assertion at the boundary, and delete the guard.

If you find a good one, bring the trace to the club — we've got a #debugging channel that is basically people posting stack traces and other people saying "that's not where the bug is". discord.gg/3scUHe7B if you want in.

Timothy Yang

Timothy Yang

Founder & CEO, DrillCall

Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.

Build with us

Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.

JOIN THE DISCORD →