TypeScript Code Review Patterns
Stack-specific rules loaded by dh:code-reviewer when tsconfig.json, *.ts, or *.tsx files are detected.
Strict Mode
tsconfig.jsonmust enablestrict: true— this coversnoImplicitAny,strictNullChecks,strictFunctionTypes, and othersexactOptionalPropertyTypes: trueis required in new projects — preventsundefinedfrom being assigned to optional propertiesnoUncheckedIndexedAccess: trueis required when indexing arrays or records — prevents silentundefinedpropagation- Any
@ts-ignorecomment without an accompanying explanation comment is a blocking finding @ts-expect-erroris preferred over@ts-ignore— it fails if the error goes away
Type Safety
anytype without an explanatory comment is a blocking finding- Type assertions (
as SomeType) without runtime validation at the same boundary are a blocking finding unknownis the correct type for values from external sources — validate before narrowing, not afterobjectas a type is not meaningful — useRecord<string, unknown>or a specific interface- Non-null assertions (
value!) without a comment explaining why null is impossible are a blocking finding
Discriminated Unions Over Booleans
- Multiple boolean flags encoding state are a blocking finding — model as a discriminated union instead
- State machine logic with
isLoading,isError,isSuccessas separate booleans allows impossible combinations
// WRONG: boolean flags allow impossible states
interface State {
isLoading: boolean;
isError: boolean;
data: User | null;
}
// RIGHT: discriminated union — impossible states are unrepresentable
type State =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: User };
Branded Types
- Domain primitives that are structurally identical but semantically distinct must use branded types to prevent mix-ups
UserIdandOrderIdare bothstringat runtime — without brands, they are interchangeable to the type checker
type UserId = string & { readonly _brand: "UserId" };
type OrderId = string & { readonly _brand: "OrderId" };
function makeUserId(id: string): UserId {
return id as UserId;
}
ESM
require()calls are a blocking finding — useimportsyntax- Named exports are preferred over default exports — easier to refactor and search
import typemust be used for type-only imports — prevents runtime errors and improves tree-shaking- Dynamic
import()must be typed with the expected module shape
Async Patterns
- Floating promises (calling an async function without
awaitor.then()/.catch()) are a blocking finding Promise.allis required for parallel independent async operations — sequentialawaitin a loop is an anti-pattern when operations are independentawaitinside aforloop that processes independent items is a blocking finding- Unhandled promise rejections must have explicit error handling at the call site
Runtime Safety
- User-controlled input entering the system must be validated against a schema (zod, valibot, or equivalent) at the boundary — no raw
as UserTypecasts on external data - JSON.parse results must be validated before use —
JSON.parse(text) as MyTypeis a blocking finding - Environment variables must be validated at startup with specific error messages —
process.env.API_KEY!without validation is a blocking finding
satisfies Operator
satisfiesis preferred over explicit type annotations for config objects and record literals — preserves the literal type while validating against the declared type- Use when you want both type checking AND the narrowed type available downstream
// RIGHT: satisfies preserves literal types
const config = {
port: 3000,
host: "localhost",
} satisfies ServerConfig;
// config.port is typed as 3000, not number
Anti-Patterns
// WRONG: any without comment
function process(data: any) { ... }
// RIGHT: specific type or documented any
function process(data: unknown) {
if (!isUserEvent(data)) throw new TypeError("Expected UserEvent");
...
}
// WRONG: floating promise
sendMetrics(event);
// RIGHT: awaited or explicitly fire-and-forget
void sendMetrics(event); // intentionally not awaited — best-effort telemetry
// or
await sendMetrics(event);