Reviewing TypeScript code - what to flag, and what the compiler and linter already flag for you
USE WHEN: you are reviewing, critiquing or auditing existing TypeScript or
TSX code - a "code review", a "review" of a .ts/.tsx file, a diff, a PR or a
pull request; deciding what to comment on in TypeScript; avoiding false
positives on typed JavaScript
DO NOT USE FOR: writing, explaining or learning TypeScript - use
`languages/typescript`; anything `tsc` under `strict` or a default
typescript-es...
Installs into .claude/skills of the current project.
Are you the author of Typescript?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/claude-dev-suite-typescript-claude-dev-suite)
---
name: typescript-review
description: |
Reviewing TypeScript code - what to flag, and what the compiler and linter already flag for you
USE WHEN: you are reviewing, critiquing or auditing existing TypeScript or
TSX code - a "code review", a "review" of a .ts/.tsx file, a diff, a PR or a
pull request; deciding what to comment on in TypeScript; avoiding false
positives on typed JavaScript
DO NOT USE FOR: writing, explaining or learning TypeScript - use
`languages/typescript`; anything `tsc` under `strict` or a default
typescript-eslint run already reports (this skill lists those so you can stay
silent about them); React-specific review - pair with the React skills; lint
rule configuration - use `quality/typescript-eslint`
allowed-tools: Read, Grep, Glob
---
# Reviewing TypeScript
A reviewer's leverage is what the toolchain cannot say. Every check below
describes a defect that survives `tsc --strict` and a default
`typescript-eslint` run.
TypeScript has a second trap Go does not: **the compiler's strictness is a
setting**. Half of what follows depends on `tsconfig.json`, so read it before
you comment - see the config-dependent section.
The snippets are fragments cut down to the defect, not runnable programs.
## Already covered - do not spend review on it
| Defect | Reported by |
|---|---|
| Unused local, unused parameter | `noUnusedLocals` / `noUnusedParameters`, `@typescript-eslint/no-unused-vars` |
| Explicit `any` in a signature | `@typescript-eslint/no-explicit-any` (recommended) |
| `==` where `===` belongs | `eqeqeq` |
| A promise created and never used | `@typescript-eslint/no-floating-promises` - **type-aware only**, so absent unless the project enables type-checked linting |
| `await` on a non-thenable | `@typescript-eslint/await-thenable` (type-aware) |
| Unreachable code after `return` | `allowUnreachableCode: false` |
| Missing `return` on some path | `noImplicitReturns` |
| Implicit `any` parameter | `noImplicitAny` (part of `strict`) |
Check whether type-aware linting is on before assuming the promise rules ran:
they need `parserOptions.project`, and many repos never enable it. If they are
off, floating promises **are** worth reviewing by hand - that is a config
finding plus a code finding, not one comment.
> **The table above dates; the tools do not.** Verify a default set with
> `mcp__documentation__fetch_docs` on technology `eslint` — topics `rules`, `typescript-eslint` — and `typescript`/`tsconfig` for the compiler settings.
> Those entries point at the indexes the tool authors maintain, so they follow
> the version the project actually pins rather than the one this skill was
> written against.
## The checks that earn their place
### A type assertion that launders a lie
```ts
const user = JSON.parse(body) as User; // no validation happened
const el = document.getElementById('x') as HTMLInputElement; // may be null
```
**When you see it**: `as T` on a value crossing a trust boundary - a parsed
payload, a DOM lookup, a `fetch` result, a database row.
**Ask**: what checked that this is actually a `User`? An assertion changes the
type and nothing else; every downstream `strict` guarantee is now resting on it.
The fix is a validator (zod, a type guard) or accepting `unknown` and narrowing.
`no-explicit-any` does not fire here - the code never says `any`, it says
something worse: a specific type that is not true.
### A type predicate that is not checked
```ts
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null; // does not check a single field
}
```
**When you see it**: any function returning `x is T`.
**Ask**: does the body actually establish `T`? The compiler takes the predicate
on faith - it verifies the *return type is boolean*, never that the check is
sound. A wrong predicate is an assertion with extra steps, and it silently
poisons every narrowed branch.
### A non-null assertion standing in for a real check
```ts
const cfg = map.get(key)!; // `!` says "trust me"; the map may not have it
```
**When you see it**: `!` after an indexed access, a `Map.get`, a `find`, or an
optional property.
**Ask**: what guarantees presence *here*? If it is an invariant, an explicit
throw documents it and fails loudly; `!` fails as `undefined` propagating into
something far away. Distinguish the two cases: `!` right after a `has()` check
on the same key is fine, `!` on a lookup whose key came from input is not.
### Optional chaining that swallows the interesting case
```ts
const total = order?.items?.reduce(sum, 0) ?? 0; // a missing order reads as 0
```
**When you see it**: `?.` on a value the surrounding logic assumes exists,
especially followed by `?? 0`, `?? []` or `?? ''`.
**Ask**: is absence genuinely equivalent to empty? A zero total for a missing
order is not a safer answer than an error - it is a wrong answer that no longer
looks wrong. `?.` is right when absence is a real, expected state.
### `catch (e)` narrowed by assumption
```ts
try { } catch (e) {
logger.error(e.message); // e is `unknown`; and anything can be thrown
}
```
**When you see it**: property access on a catch binding.
**Ask**: is this reachable with a non-`Error` throw? A rejected promise carrying
a string, a `throw` from a library, a DOMException. Under
`useUnknownInCatchVariables` (on with `strict` since 4.4) the compiler stops the
direct access - but an `as Error` right after it is the same defect wearing a
hat, and that compiles.
### An `await` that was never written inside a callback
```ts
items.forEach(async (item) => {
await save(item); // forEach ignores the returned promise
});
done(); // runs before any save finishes
```
**When you see it**: an `async` callback handed to `forEach`, `map` without
`Promise.all`, or any API whose signature returns `void`.
**Ask**: does the caller need these to be finished? `no-floating-promises` does
not fire, because `forEach` swallows the promise rather than the code
discarding it. `for...of` with `await`, or `Promise.all(items.map(...))`, is the
fix - and those two differ in concurrency, so pick deliberately.
### `Promise.all` where partial failure is the normal case
```ts
const results = await Promise.all(userIds.map(fetchProfile));
```
**When you see it**: `Promise.all` over anything that can fail independently -
network calls, per-tenant work, a fan-out over user input.
**Ask**: what should happen when one fails? `all` rejects on the first
rejection, abandons the results of everything that succeeded, and leaves the
other promises running unobserved. If partial success is meaningful, that is
`allSettled`.
### A shallow copy treated as a deep one
```ts
const next = { ...state };
next.filters.tags.push('new'); // mutates the original's nested array
```
**When you see it**: spread or `Object.assign` followed by a write to a nested
property; `readonly` on a type whose members are objects.
**Ask**: is anything else holding the nested reference? `readonly` in TypeScript
is one level deep and erased at runtime - it stops `next.filters = ...` and says
nothing about `next.filters.tags.push(...)`.
### An index access typed as present
```ts
const first = rows[0]; // typed `Row`, not `Row | undefined`
first.id; // no error, throws at runtime on an empty array
```
**When you see it**: indexing an array or a `Record<string, T>` by a computed
key, then using the result directly.
**Ask**: is `noUncheckedIndexedAccess` on? It is **not** part of `strict`, so in
most projects this is invisible to the compiler and every array access is a
claim that the index exists.
### A discriminated union with no exhaustiveness guard
```ts
switch (action.type) {
case 'add': return add(action);
case 'remove': return remove(action);
} // a third variant added later returns undefined, silently
```
**When you see it**: a `switch` over a union discriminant with no `default`.
**Ask**: what happens when a variant is added? A `default` that assigns to
`never` turns that future change into a compile error at exactly the right
place. Without it the function's return type quietly becomes `T | undefined`.
### Numeric sort left to the default comparator
```ts
[10, 9, 100].sort(); // [10, 100, 9] - compares as strings
```
**When you see it**: `.sort()` with no comparator on anything not already
strings.
**Ask**: are these numbers? Nothing reports it; the signature is satisfied.
## Config-dependent - read `tsconfig.json` before commenting
Half the checks above are only defects under a given configuration. Read the
config the reviewed file actually resolves to - a monorepo has several, and
`extends` chains matter.
| Setting | In `strict`? | What its absence means for the review |
|---|---|---|
| `strict` | - | Off: implicit `any` and unchecked null are everywhere; that is one architectural comment, not a per-line campaign |
| `strictNullChecks` | yes | Off: **every** nullability finding above is invisible to the compiler and worth reviewing by hand |
| `useUnknownInCatchVariables` | yes (4.4+) | Off: `catch (e)` is `any`, so `e.message` compiles and the catch check applies |
| `noUncheckedIndexedAccess` | **no** | Off (the common case): array and record access lie about presence |
| `exactOptionalPropertyTypes` | **no** | Off: an optional property silently accepts an explicit `undefined`, which behaves differently from an absent key in `Object.keys`, spread and JSON |
| `verbatimModuleSyntax` | **no** | Off: a type-only import can survive into emitted JS and drag a runtime dependency with it |
If the project sets `skipLibCheck` (most do), remember that nothing verified the
`.d.ts` files a third-party type assertion is resting on.
## What to say
Anchor the comment to the line, name the condition that triggers the defect, and
say what breaks: "`rows[0]` is typed `Row` but `noUncheckedIndexedAccess` is off,
so an empty result throws on `.id` here" beats "be careful with array access".
If you cannot state the input that fails, it is a preference, not a defect.
State configuration findings once, at the top, not once per line they enable.