The TypeScript mistakes language models actually make
The compiler catches a lot. What survives is almost always the compiler being told to look away.
TypeScript's failure modes are unusual: the language has a good static checker, so the interesting bugs are the ones where the checker was disabled, explicitly or by a loose config.
That makes review here mostly mechanical. There is a short list of escape hatches, they are all greppable, and each one is a place where a claim was made that nothing verified.
The escape hatches#
1. as on external data#
const user = await res.json() as User; // a claim, not a check
const config = JSON.parse(raw) as Config;
const el = document.querySelector(".x") as HTMLInputElement;as is an assertion. Nothing runs. If the API changed a field name last week, the type still says it did not, and the failure surfaces three functions away as undefined is not an object.
Correct: parse with a schema and let the type flow out of it.
const User = z.object({ id: z.string(), email: z.email() });
const user = User.parse(await res.json());2. Non-null assertions#
const first = items.find(x => x.id === id)!; // "trust me"
process.env.API_KEY!.slice(0, 4);Each ! is an unchecked claim, and generated code produces them whenever narrowing would take an extra line. Catch it with: @typescript-eslint/no-non-null-assertion.
3. any, explicit and implicit#
any disables checking for everything it touches and spreads through inference. Generated code reaches for it under pressure — an awkward generic, a library without types.
Correct: unknown plus a narrowing check. It is one more line and it does not spread.
Catch it with: no-explicit-any and no-unsafe-assignment / no-unsafe-member-access / no-unsafe-call from the type-checked preset. Those three catch any arriving from an untyped dependency, which is where most of it comes from.
4. @ts-ignore#
// @ts-ignore
doTheThing(wrongArgs);Correct: @ts-expect-error, which errors if the line stops being wrong — so it cannot rot silently. Ban @ts-ignore outright with ban-ts-comment.
git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|!\.|!\)|!;'That one command is most of a TypeScript review.
Unsoundness the compiler permits by default#
5. Array and record access#
const first = items[0]; // typed T, actually T | undefined
const port = config["port"]; // sameTypeScript's default is unsound here for ergonomic reasons, and generated code assumes presence constantly. Fix it with: noUncheckedIndexedAccess: true. This is the single highest-value compiler flag for generated code.
6. Optional properties versus undefined#
type Opts = { retries?: number };
const o: Opts = { retries: undefined }; // allowed by default. usually a bug.Fix it with: exactOptionalPropertyTypes: true.
7. Structural typing surprises#
function transfer(from: string, to: string, amount: number) {}
transfer(orgId, userId, amount); // compiles. wrong.Three strings are interchangeable because they are three strings. Fix it with: branded types — see the type system as a harness.
8. Widened literal types#
const config = { mode: "dark" }; // mode: string, not "dark"
setTheme(config.mode); // error, or worse, accepted as stringas const on config objects. Generated code omits it and then reaches for as to fix the symptom.
Async#
TypeScript inherits every JavaScript async failure — floating promises, sequential awaits, forEach with an async callback — and adds one of its own:
9. A promise where a value was expected#
if (isReady()) { } // isReady returns Promise<boolean>
// a Promise is always truthyCatch it with: @typescript-eslint/no-misused-promises and await-thenable. The full JavaScript async list is in the JavaScript failure modes and applies unchanged.
Idioms worth correcting#
| Generated | Prefer |
|---|---|
enum Status { ... } | a union of string literals, or as const — enums emit runtime code and are not erasable |
namespace | modules |
interface vs type inconsistently | pick one convention and put it in AGENTS.md |
Function, Object, {} as types | specific signatures; {} means "anything not null" |
require() in an ESM project | import |
custom DeepPartial, Awaited | built-in utility types now cover most of it |
The enum point matters more than it used to: with erasableSyntaxOnly and runtime type stripping, enums and parameter properties are no longer erasable syntax. Generated code still reaches for them constantly.
The config#
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true
}
}rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": true }],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/consistent-type-assertions": ["error", { "assertionStyle": "never" }],
}That last rule — banning as entirely — is aggressive and worth trying. In most codebases the legitimate uses are rare enough to justify with an explicit eslint-disable comment, and forcing that comment is exactly the review prompt you want.
The whole review, compressed
Turn on the four strict-adjacent flags. Ban the escape hatches in lint. Then the only TypeScript-specific thing left to read for is whether the types describe the right thing — which is judgement, and is where your attention should go.
Common questions#
Is banning as outright practical?#
More often than people expect. Genuine uses — narrowing a DOM element, as const, a well-understood cast at a library boundary — are rare enough that requiring an explicit disable comment is reasonable, and the comment is a useful review signal.
Why does generated code use enums when the ecosystem moved away from them?#
Because enums appear throughout a decade of TypeScript in the training data. They also now conflict with runtime type stripping, since they emit real JavaScript. A union of string literals does the same job, is erasable, and narrows better.
Do these rules slow the build down?#
The type-aware rules need a TypeScript program, so linting is slower — noticeably on a large repo. Use projectService and lint changed files in the edit hook, full repo in CI. The bugs they catch are worth the seconds.
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.
Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.