How to Structure Your TypeScript Codebase So AI Coding Agents Work Better

Wednesday 06/05/2026

·9 min read
Share:

You ask Claude Code to "add rate limiting to the export endpoint" and it spends six tool calls grepping the wrong directory, then writes middleware that imports a util that doesn't exist. You ask Cursor for a small refactor and it touches eight files, three of them irrelevant. The model isn't dumb — your codebase is fighting it.

I spent the last quarter restructuring a 40k-line TypeScript app specifically to make AI agents more effective on it, then measured the difference. Some patterns that felt like cosmetic cleanup (file size, naming, where tests live) had a much bigger impact on agent output quality than the loud stuff (CLAUDE.md, AGENTS.md). This post is the playbook — what actually moved the needle, what didn't, and the before/after.

Why structure matters more for agents than for humans

A human reading your code has continuity. They opened the file yesterday, they remember which module the auth helpers live in, they know the convention even when it's inconsistent. An AI coding agent has none of that — every session starts cold, every file it reads costs tokens, and every wrong file it reads pushes the right context out of its window.

So the patterns that help an agent are the patterns that maximize information-per-token:

  • The agent should be able to find the right file in one search, not three.
  • One open file should contain enough context to make a correct edit.
  • The conventions should be either obvious from a single example or written down somewhere it'll look.

Everything below is a specific lever on one of those three goals.

Pattern 1: Small, single-purpose files

This was the biggest single win. The rule we settled on: one file, one exported symbol, under ~200 lines.

A 900-line utils.ts with thirty exports is a black hole for an agent. It can't grep for what it needs without reading the whole file, and once it does, the related code (callers, tests, types) is scattered across the codebase. Every edit becomes a long-context comprehension problem.

Before

// src/lib/utils.ts (BEFORE — 900 lines)
export function formatCurrency(...) { /* ... */ }
export function parseDate(...) { /* ... */ }
export function slugify(...) { /* ... */ }
export class RateLimiter { /* ... */ }
export function exponentialBackoff(...) { /* ... */ }
// ...25 more

After

src/lib/
  format-currency.ts          # 18 lines, one export + one type
  parse-date.ts               # 22 lines
  slugify.ts                  # 14 lines
  rate-limiter.ts             # 80 lines
  exponential-backoff.ts      # 35 lines

Now when the agent searches for RateLimiter, the result is a self-contained 80-line file with the implementation, the types, and (next pattern) the tests. It can edit confidently without reading anything else.

The objection: "but now I have a hundred tiny files." Yes. That's the point — your file tree becomes a searchable index. ls src/lib/ is now a table of contents the agent can scan in 50 tokens instead of opening files to find out what they contain.

Pattern 2: Co-locate tests with source

Tests are the single best context an agent can have when editing a function. They show the contract, the edge cases, and what "correct" looks like. The default __tests__/ folder or top-level tests/ directory hides them — the agent edits the function, doesn't open the test, ships a regression.

src/lib/
  rate-limiter.ts
  rate-limiter.test.ts        # right next to it
  exponential-backoff.ts
  exponential-backoff.test.ts

When the agent reads rate-limiter.ts, your IDE/file tree puts the test file two lines away. Most agents will open it automatically when making non-trivial changes. Mine started doing this without being told, just because the file was visible in the same directory listing.

If your test runner needs configuration:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
    test: {
        include: ['src/**/*.test.ts'],
        environment: 'node',
    },
})

Pattern 3: Typed configs, not stringly-typed env access

Reading process.env.STRIPE_API_KEY directly from a feature file is fine for a human — they know which env vars exist. An agent often invents env var names that don't exist, or writes new code that reads process.env.STRIPE_KEY (different name) and doesn't realize the convention.

A single typed config module solves this:

// src/config/env.ts
import { z } from 'zod'

const envSchema = z.object({
    NODE_ENV: z.enum(['development', 'test', 'production']),
    STRIPE_API_KEY: z.string().min(1),
    STRIPE_WEBHOOK_SECRET: z.string().min(1),
    DATABASE_URL: z.string().url(),
    OPENAI_API_KEY: z.string().min(1),
    RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(60),
})

export const env = envSchema.parse(process.env)
export type Env = z.infer<typeof envSchema>
// src/lib/stripe-client.ts
import Stripe from 'stripe'
import { env } from '@/config/env'

export const stripe = new Stripe(env.STRIPE_API_KEY)

Now the agent has one file to consult to know every env var that exists. It autocompletes them correctly. It catches typos at parse time, not runtime. And when it adds a new feature that needs a new env var, the diff goes through env.ts first — visible in the PR, easy to review.

Pattern 4: One barrel per module boundary, not one per directory

Barrel files (index.ts re-exports) are controversial because they hide things. The rule that works for agents: one barrel at each public module boundary, none inside the module.

src/
  features/
    billing/
      index.ts                # PUBLIC API: re-exports the 3 things callers need
      stripe-client.ts        # internal
      subscription-service.ts # internal
      invoice-formatter.ts    # internal
      webhook-handler.ts      # internal
// src/features/billing/index.ts
export { createSubscription, cancelSubscription } from './subscription-service'
export { handleStripeWebhook } from './webhook-handler'
export type { Subscription, SubscriptionStatus } from './subscription-service'

Now the agent reading a file outside billing/ sees import { createSubscription } from '@/features/billing' and immediately knows two things: there's a billing module, and createSubscription is part of its public API. It doesn't need to grep for the implementation file unless it's editing the implementation.

Inside the module, files import each other directly — no barrel — so refactoring doesn't break tooling or create circular imports.

Pattern 5: Descriptive names that work without context

Agents pattern-match on names. A file called helpers.ts is invisible. A file called parse-stripe-webhook-event.ts advertises exactly what it does, and the agent finds it in one grep.

The naming rule: a developer (or agent) seeing only the filename should be able to predict what's inside it within a few words. Apply it ruthlessly:

  • utils.ts → split into named files
  • helpers.ts → split into named files
  • service.tssubscription-service.ts
  • index.ts for non-barrel files → rename
  • types.ts with twenty unrelated types → split per concept

Same rule for symbols. processData() is a black hole; parseStripeWebhookEvent() is a contract.

Pattern 6: CLAUDE.md / AGENTS.md at the root

This is the loud one. It helps, but less than people claim — because most CLAUDE.md files I've seen are bloated docs about architecture that agents don't actually read mid-task. What works is a short file with the conventions an agent can't infer from the code.

Keep it under 100 lines. Mine looks like:

# CLAUDE.md

## Build & Development Commands
- `pnpm dev` — start dev server
- `pnpm test` — run tests (Vitest)
- `pnpm typecheck` — tsc --noEmit
- `pnpm lint` — ESLint

## Conventions
- One exported symbol per file. File name = kebab-case of the symbol.
- Tests live next to source: `foo.ts` + `foo.test.ts`.
- All env access goes through `src/config/env.ts`.
- Public module APIs are re-exported from `src/features/*/index.ts`.
- No `any`. No `// @ts-ignore` without a comment explaining why.

## Where things live
- `src/features/` — vertical slices (billing, auth, search)
- `src/lib/` — generic utilities (no domain logic)
- `src/config/` — typed config (env, constants, feature flags)
- `src/pages/api/` — Next.js API routes (thin — call into features/)

## Things to NOT do
- Do not edit `pnpm-lock.yaml` manually. Use `pnpm install`.
- Do not add new top-level directories without discussing first.
- Do not introduce `any` to silence a type error — fix the type.

The format that compounds: every "convention" line should be enforceable by an example in the codebase. The agent reads the rule, then sees five files that follow it, and it generalizes correctly.

AGENTS.md is the same idea for tools that look for that filename instead — Cursor, OpenAI Codex, and a few others. I just symlink: ln -s CLAUDE.md AGENTS.md. Same content, double the agent coverage.

For more on writing these files well, see my post on building a multi-agent system where I cover how agents actually consume context files.

Pattern 7: Per-feature README for non-obvious modules

Most modules don't need a README. The ones that do are the ones with invariants the code can't express: a state machine, a webhook ordering requirement, a non-obvious caching policy.

src/features/billing/
  README.md
  index.ts
  ...
# Billing module

## Subscription state machine
States: `trialing``active``past_due``canceled`.
Transitions are driven exclusively by Stripe webhooks. Never set state from the app.

## Webhook idempotency
Stripe retries on 5xx. Every webhook handler must be idempotent — keyed by `event.id`.
We persist processed event IDs in `webhook_events` for 30 days.

## Things that have bitten us
- Customer IDs from Stripe are NOT our user IDs. Always look up via `customer_id_to_user_id`.
- The `invoice.payment_succeeded` event fires before `customer.subscription.updated` for new subs. Order-sensitive code must handle out-of-order events.

Three short sections, all "things you'd only know from being burned by them." Exactly the kind of context an agent needs to make a correct change in this directory and would otherwise have to discover by reading the entire module.

Measuring the impact

I tracked a single metric across the refactor: percentage of agent-completed tasks that passed CI on the first attempt. Same agent (Claude Code), same kinds of tasks, sampled before and after.

| Pattern applied | First-pass CI pass rate | | --- | --- | | Baseline (utils.ts, scattered tests, no CLAUDE.md) | 41% | | + Small files + co-located tests | 64% | | + Typed env + module barrels | 73% | | + CLAUDE.md + per-module READMEs | 81% |

The biggest jump came from file size and test colocation — the boring stuff. The CLAUDE.md additions helped at the margin but were nowhere near as load-bearing as people claim. The real lesson: agents are bottlenecked on finding and reading the right files, and structural changes to the codebase fix that more than instructions ever will.

What's next

If you're using AI agents for coding, the next thing worth dialing in is observability — knowing what the agent actually did and where it went wrong. I cover that in How to Add LLM Observability and Tracing to Your TypeScript AI App with Langfuse, which pairs well with the structural changes here. Restructure to make the agent more effective, then trace its work to see where it still struggles.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts