Writing an AGENTS.md for TypeScript
Half of what people put in a TypeScript instructions file belongs in tsconfig.json instead, where it is enforced rather than suggested.
AGENTS.md is a Markdown file in your repository root that coding agents read before starting. Claude Code reads CLAUDE.md; most other tools read AGENTS.md. Write one, symlink the other:
ln -s AGENTS.md CLAUDE.mdTypeScript has an unusual property that should shape how you write this file: a large share of what you would put in prose can be expressed as a compiler flag or a lint rule instead. A rule in tsconfig.json is checked on every edit and cannot be deprioritised. A rule in AGENTS.md competes for attention with everything else in the window.
So the first question for every candidate line is: can this be a config option? If yes, it belongs there.
Move these out of prose#
| Instead of writing… | Set this |
|---|---|
| "Don't assume array elements exist" | noUncheckedIndexedAccess: true |
"Don't use any" | @typescript-eslint/no-explicit-any |
| "Don't use non-null assertions" | no-non-null-assertion |
| "Always await promises" | no-floating-promises (needs type info) |
"Use @ts-expect-error, not @ts-ignore" | ban-ts-comment |
| "Don't cast, parse" | consistent-type-assertions: { assertionStyle: "never" } |
| "Handle every switch case" | noFallthroughCasesInSwitch + assertNever |
| "Use import type for types" | verbatimModuleSyntax: true |
That is eight lines removed from the file and made mandatory instead. Full reasoning for each in the TypeScript failure modes.
The target
Under 80 lines, with the enforceable half pushed into tsconfig.json and eslint.config.js. If your file is 300 lines of TypeScript style advice, most of it is either already the model's default or should be a rule.
What actually belongs in prose#
Commands. Unguessable, used every turn.
Where the boundaries are. Which directory is allowed to import which. A model cannot infer your layering from the code and no linter knows it unless you configure one.
Domain types. That money is Cents, that ids are branded, that dates are always Temporal.Instant in UTC. These are conventions the type system enforces once you use them — but the model has to know to reach for them.
Which of the seven ways you use. TypeScript has more than one reasonable answer to most questions: interface or type, enums or unions, classes or functions, Zod or Valibot, Vitest or Jest. Pick one of each and say so. The cost of not saying is a codebase that drifts into using all of them.
Landmines.
The template#
TypeScript 5.x strict, Node 22, pnpm, ESM only.
## Commands
- Check: `pnpm check` (tsc --noEmit && eslint . && vitest run). Must pass.
- Types: `pnpm tsc --noEmit`
- One test: `pnpm vitest run src/thing.test.ts`
- Fix: `pnpm eslint . --fix`
## Layout and boundaries
- `src/domain/` pure logic. No I/O, no framework imports, no fetch.
- `src/adapters/` db, http clients, queues. The only place secrets are read.
- `src/api/` route handlers. Parse, call domain, serialise. No logic.
- `src/schemas/` zod schemas. One per external boundary.
- Tests live beside the file: `thing.ts` -> `thing.test.ts`.
domain/ must not import from adapters/ or api/. This is enforced by
eslint-plugin-import boundaries — if the rule fires, the design is wrong,
not the rule.
## Types are the specification
- External data is PARSED with a zod schema, never cast. `as` on a fetch,
a JSON.parse or a database row is a bug.
- Model state as a discriminated union, not optional fields. Make illegal
states unrepresentable.
- Ids and money are branded: UserId is not OrgId is not string; money is
Cents (a branded integer), never a float.
- Exhaustive switches end with `assertNever(x)`.
- Prefer `type` over `interface` unless you need declaration merging.
- Unions of string literals, not enums — enums emit runtime code and are
not erasable syntax.
## Pick-one decisions (do not introduce alternatives)
zod (not valibot) · vitest (not jest) · pnpm (not npm) · Result-style returns
in domain/, thrown errors at the api/ boundary.
## Landmines
- `src/adapters/legacy-sync.ts` is called by the ops repo over CLI. Its
arguments and exit codes are a contract.
- `src/schemas/webhook.ts` mirrors a third-party payload. Do not "tidy"
field names — they must match the wire format exactly.Note how much of it is decisions rather than advice. That is the shape that works: the model already knows how to write TypeScript, it does not know which of six reasonable options your codebase chose.
Config that carries the enforceable half#
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext"
}
}Then wire tsc --noEmit into a post-edit hook so type errors arrive within a second of the model writing them, rather than at the end of the turn. On a large repo use --incremental or project references to keep it fast enough to run every time.
Monorepos#
Root file for commands and shared decisions; per-package files for the specifics.
AGENTS.md commands, pick-one decisions, global boundaries
packages/api/AGENTS.md route conventions, auth rules
packages/ui/AGENTS.md component patterns, styling decision
packages/shared/AGENTS.md "this is imported by everything — changes are breaking"That last one is a landmine worth stating explicitly in every monorepo.
Common questions#
Should I put my whole style guide in here?#
No. Anything a formatter or linter can enforce should be enforced, and everything else should be short enough that the model actually weights it. A style guide is documentation — put it in docs/ and add one line: "read docs/style.md before changing anything under src/ui/."
interface or type?#
Pick one and write it down; the choice matters far less than the consistency. type is the more common default now because it handles unions and intersections uniformly; interface is worth it if you rely on declaration merging or are publishing types for others to extend.
How does this interact with Cursor rules?#
Cursor reads its own .cursor/rules files, and increasingly also AGENTS.md. Keep the substance in AGENTS.md and make the tool-specific files thin pointers to it, otherwise you maintain two documents that disagree within a month.
Get the TypeScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.