Claude Code Creates a Pile of Unused Helpers

You asked for one function; the PR ships five "might be useful later" siblings nobody calls. Bound scope in the prompt and CLAUDE.md, auto-delete dead exports with knip --fix, and lock it in CI.

You asked Claude Code to add a formatPrice function. The PR adds formatPrice — and also formatCurrency, formatPercent, formatCompact, parseAmount, and validatePrice. None of those five are called anywhere. The PR description says “complete formatting utilities suite.” You wanted one function; you got a small framework.

Fastest fix: give the prompt an explicit DO NOT add list, then run pnpm dlx knip --fix --fix-type exports to strip every dead export automatically. The rest of this page makes that default stick so you stop catching it on review.

This is over-engineering on autopilot. Claude defaults to “complete solutions” because that pattern is rewarded in its training data. On a real codebase, every unused export is maintenance burden, confusion for the next dev, and extra surface area for bugs. The fix is two-layered: bound scope at the prompt + CLAUDE.md level so it rarely happens, and delete (or auto-strip) whatever slips through on review.

Which bucket are you in?

Symptom you seeMost likely causeGo to
Prompt was generic (“add price helpers”)Scope never boundedCause 1, Step 1
Extra exports form a tidy family (formatX, parseX, validateX)“Comprehensive” autopilotCause 2, Step 4
New file sits alone in an empty dirNo anchor for module sizeCause 3
Prompt said “module” / “library” / “suite”Plural framing authorized itCause 4
It keeps happening every sessionCLAUDE.md silent on YAGNICause 5, Step 2
New API mirrors date-fns / lodash shapePattern-matched a big libraryCause 6

Common causes

Ordered by hit rate, highest first.

1. The prompt didn’t bound the scope

“Add a price formatter” doesn’t say “only the one I need.” Claude assumes “any reasonable utility related to prices” is in scope and ships the full set.

How to spot it: Your prompt is generic (“add X helpers”). Re-prompt with “add EXACTLY formatPrice(cents): string. Nothing else.”

2. Claude defaults to “comprehensive solutions”

Training data is full of “complete X library” examples. When asked for one function, Claude tends to provide a function plus its four obvious siblings, because that is what looks “professional.”

How to spot it: The unused helpers form a natural family with the requested one. That is the autopilot mode.

3. No existing utils file to anchor against

A greenfield area means Claude has no reference for “how big a utility module should be.” It invents a complete-looking module by default.

How to spot it: The new file lives in a directory with no peers. Claude designed a module shape from scratch.

4. The task implicitly suggested a “library” framing

“Build a date utility module” reads as “build a library of date utilities.” Claude builds the library. The actual ask might have been “I need formatDateForInvoice.”

How to spot it: Words like module, library, suite, or set of utilities in the prompt. These authorize plurality.

5. CLAUDE.md is silent on YAGNI

Without an explicit “don’t design for future use” rule, Claude’s default is “design for plausible future.” YAGNI (You Aren’t Gonna Need It) has to be an explicit policy, not an assumed culture.

How to spot it: grep -i "yagni\|unused\|only what's needed" CLAUDE.md returns nothing.

6. Claude replicated the shape of a big library it saw in training

date-fns ships hundreds of exports. Claude saw it and concludes “date utilities means lots of exports.” Your project does not need all that, but Claude cannot tell from context.

How to spot it: The helpers mirror a popular library’s API. Claude pattern-matched instead of designing for your need.

Shortest path to fix

Ordered by ROI. Steps 1 and 2 prevent over-engineering before it happens; Step 3 cleans up what slips through.

Step 1: Use scope-bounded prompts

Add EXACTLY this:
- Function: formatPrice(cents: number, currency: "USD"): string
- File: src/lib/formatPrice.ts
- Test: src/lib/formatPrice.test.ts
- 3 test cases: zero, normal, large amounts

DO NOT add:
- Other formatters (formatCurrency, etc.) — even if they seem useful
- A "utilities barrel" or index file
- Helper functions only used internally — inline them
- Documentation beyond JSDoc on the export

The DO NOT list is the lever. Without it, Claude fills the gap with plausible additions.

Step 2: Add a YAGNI rule to CLAUDE.md

Per the Claude Code memory docs, CLAUDE.md is loaded into every session, so a short policy here changes the default for the whole project:

## YAGNI policy

- Write only the code required for the current task.
- Do not design for future requirements unless they're scheduled.
- A new helper requires >= 2 callers to ship. One-caller code stays inlined.
- A new abstraction requires 3 concrete uses. Premature abstraction is rejected at review.
- "Might be useful later" is not a reason to add code. Delete and re-add later when needed.

Keep the root CLAUDE.md tight (the docs recommend roughly under 300 lines); a prose rule is a nudge, not a guarantee, which is why Step 6 moves enforcement into a hook and CI.

Step 3: Auto-strip unused exports with knip

After the PR lands, find and delete dead exports. knip (v6.x as of June 2026) is the current standard for this on TypeScript/JavaScript projects, and it can fix in place:

# Report unused files, exports, and dependencies
pnpm dlx knip

# Auto-remove only the unused exports/types (safest scope)
pnpm dlx knip --fix --fix-type exports,types

# Reformat the files knip touched with your project's formatter
pnpm dlx knip --fix --fix-type exports,types --format

--fix strips the export keyword from unused exports (downgrading them to local) and removes unused default exports; add --allow-remove-files only if you also want it to delete files that become fully unused. Re-run plain pnpm dlx knip afterward to confirm a clean report. If you prefer a narrower tool, pnpm dlx ts-unused-exports tsconfig.json reports unused exports without touching files.

Step 4: Reject the broad file shape, re-prompt narrower

If Claude returned a “complete utilities” file, push back in the same session so the correction sticks:

Your file has 6 exports; the task needed 1. Delete:
- formatCurrency
- formatPercent
- formatCompact
- parseAmount
- validatePrice

Keep only `formatPrice`. Re-do the test file to test only `formatPrice`.

This trains the session to stay minimal for the rest of the run.

Step 5: Inline helpers used by exactly one caller

If Claude created an internal helper used in one place, inline it:

// Bad — single-use helper
function calculateTax(amount: number): number {
  return amount * 0.08;
}

function checkout(cart: Cart): Receipt {
  const tax = calculateTax(cart.subtotal);
  // ...
}

// Good — inlined, no helper
function checkout(cart: Cart): Receipt {
  const tax = cart.subtotal * 0.08;
  // ...
}

Helpers are for repeated use; one-shot logic stays inline.

Step 6: Make the rule deterministic with a hook + CI

A CLAUDE.md rule can be interpreted loosely; a Claude Code hook cannot. Run knip as a PostToolUse hook after edits so Claude sees and fixes new dead exports inside the same session. In .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "pnpm dlx knip --no-progress || exit 2" }
        ]
      }
    ]
  }
}

Exit code 2 blocks and feeds the failure back to Claude so it cleans up before continuing. Mirror the same check in CI (pnpm dlx knip as a required job) to fail any PR that introduces an unused export, so nothing reaches main on autopilot.

Step 7: Audit existing helpers quarterly

Helpers accumulate silently. Spot the bloated files:

# How many exports does each utils file have?
find src/lib src/utils -name "*.ts" -exec sh -c \
  'echo "$(grep -c "^export" "$1") $1"' _ {} \; | sort -nr | head -20

Files with many exports get reviewed: which exports are actually called? Drop the unused ones (or let knip --fix do it).

How to confirm it’s fixed

  1. pnpm dlx knip reports 0 unused exports in the changed files.
  2. The new file exports only what the task asked for (one symbol, in the formatPrice case).
  3. Your build and tests still pass — knip --fix only removed exports nothing imported, so nothing should break.
  4. The PostToolUse hook (Step 6) now blocks if a future edit reintroduces a dead export.

FAQ

Will knip --fix delete code that’s used dynamically (string-keyed imports, DI containers)? It can, because static analysis cannot always see dynamic access. Start with --fix-type exports,types (which only downgrades export to local, not delete logic) and review the diff. Add entries to knip.json entry/ignore for files loaded dynamically before enabling --allow-remove-files.

Why does Claude keep doing this even after I tell it once? A per-prompt instruction only lasts that turn. Move the rule into CLAUDE.md (Step 2) so it loads every session, and enforce it with a hook plus CI (Step 6) so it’s deterministic rather than a suggestion Claude can rationalize around.

Is over-engineering a sign I should switch models? No. It’s a prompting and guardrail problem, not a capability gap. Claude Code runs Anthropic models only (Sonnet 4.6 and Opus 4.7 as of June 2026); both follow an explicit DO NOT add list and a YAGNI policy well. The behavior comes from vague scope, not the model tier.

knip vs ts-unused-exports vs ESLint’s no-unused-vars — which do I need? no-unused-vars only catches unused locals within a file, not unused exports. ts-unused-exports finds unused exports but won’t touch files or dependencies. knip finds unused files, exports, types, and dependencies in one pass and can auto-fix — it’s the broadest of the three.

Should the YAGNI policy live in CLAUDE.md or in a CI check? Both. CLAUDE.md shapes the default so Claude writes less dead code in the first place; CI (and the hook) is the hard backstop. The Claude Code memory docs explicitly recommend enforcing non-negotiables in CI or hooks rather than relying on prose alone.

Prevention

  • CLAUDE.md YAGNI policy: a new helper needs >= 2 callers, a new abstraction needs >= 3 uses
  • Every code prompt carries an explicit DO NOT add list to block plausible additions
  • Run knip in a PostToolUse hook and as a required CI job; fail PRs that introduce unused exports
  • Reject “might be useful later” reasoning in review — re-add when it’s actually needed
  • Avoid framing prompts as “build a library / module / utility suite”; those words authorize plurality
  • One-shot logic stays inlined; helpers are for repeated use only

Tags: #Claude Code #Debug #Troubleshooting #YAGNI #Over-engineering