
Typescript Hero
- 1 installs
- 1 repo stars
- Updated April 22, 2026
- rodrigooler/typescript-hero
Enforces strict TypeScript rigor when writing, reviewing, refactoring, or migrating .ts/.tsx code, with a zero-any policy and modern TS 5.x idioms.
About
A skill that applies staff-level TypeScript rigor: no any, strict tsconfig, parse-don't-validate at boundaries, and making illegal states unrepresentable. A developer uses it whenever TypeScript code or types, tsconfig, generics, or JS-to-TS migration are involved.
- Zero-any policy, maximally-strict tsconfig and modern TS 5.x idioms
- Parse-don't-validate with Zod/Valibot, branded types, Result<T,E>, exhaustive never
Typescript Hero by the numbers
- 1 all-time installs (skills.sh)
- Ranked #984 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rodrigooler/typescript-hero --skill typescript-heroAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | April 22, 2026 |
| Repository | rodrigooler/typescript-hero ↗ |
What it does
Enforces strict TypeScript rigor when writing, reviewing, refactoring, or migrating .ts/.tsx code, with a zero-any policy and modern TS 5.x idioms.
Files
TypeScript Hero
Type-system rigor at staff-engineer level. Treat types as a correctness tool, not decoration. Runtime bugs catchable at compile time are a process failure.
Non-negotiable rules
1. No `any`. Not in signatures, generics, casts, @ts-ignore, ambient declarations, or "temporarily". Use unknown and narrow. See references/no-any-playbook.md for all 16 replacement scenarios. 2. Parse, don't validate. Every external data boundary (HTTP, JSON.parse, localStorage, env vars, form input, LLM output, IPC) crosses through a schema parser (Zod/Valibot/ArkType). No casting past the boundary. See references/runtime-safety.md. 3. Make illegal states unrepresentable. Discriminated unions with literal tags + branded types. If your type allows a state you check at runtime, the type is wrong. 4. Total > partial functions. Expected failures return Result<T, E> or a discriminated union. throw is reserved for invariant violations (bugs), not control flow. See references/error-handling.md. 5. Immutable by default. readonly on every property, readonly T[] on array inputs, as const on literal data. 6. Infer internally, annotate at boundaries. Public/exported surface is fully annotated (return type included). Internal code lets TS infer. 7. Every `as` is a lie unless post-parser or post-type-guard. Treat like eval: rare, commented, reviewed.
Workflow — when invoked
Step 1 — Inspect (read-only)
npx tsc --version && node -v
npx tsc --showConfig | head -120
ls -1 pnpm-workspace.yaml turbo.json nx.json lerna.json 2>/dev/null
node -e 'const p=require("./package.json");const d={...p.dependencies,...p.devDependencies};for (const k of ["zod","valibot","arktype","effect","ts-pattern","@total-typescript/ts-reset","neverthrow"]) if (d[k]) console.log(k,d[k])' 2>/dev/nullAdapt to what exists. Don't impose Zod if the project uses Valibot. Don't restructure into a monorepo to fix a type error.
Step 2 — Audit tsconfig
Verify these flags before touching code. Missing or false → fix first.
"strict": true"noUncheckedIndexedAccess": true"exactOptionalPropertyTypes": true"noImplicitOverride": true"noFallthroughCasesInSwitch": true"noPropertyAccessFromIndexSignature": true"verbatimModuleSyntax": true"isolatedModules": true(and"isolatedDeclarations": truefor libraries)"skipLibCheck": true
Full baseline config, flag-by-flag rationale, monorepo setup, and lint rules in references/strict-tsconfig.md.
Step 3 — Route to the right reference
Pick the references to load based on what the task needs. Do not load everything.
| Task | Load |
|---|---|
Any any appears, or replacing one | references/no-any-playbook.md |
| tsconfig setup, monorepo, lint rules | references/strict-tsconfig.md |
satisfies, NoInfer, using, isolatedDeclarations, variance, ts-reset | references/modern-features.md |
| Branded/phantom types, discriminated unions, state machines, template literals, HKT, enums, utility types | references/advanced-patterns.md |
| External data entering the system (HTTP/env/LLM/form/storage) | references/runtime-safety.md |
Error handling, Result, typed errors, async pitfalls | references/error-handling.md |
| Designing a library API, event emitter, HTTP client, builder, DI, route registry | references/api-design.md |
| Non-trivial generics or DSLs | references/testing-types.md |
Slow tsc, large monorepo, IDE lag | references/performance.md |
| Reviewing a PR | references/code-review-checklist.md |
Step 4 — Validate
npx tsc --noEmit
npx vitest run --typecheck --run 2>/dev/null || true
npm run -s lint 2>/dev/null || npx biome check . 2>/dev/null || npx eslint . 2>/dev/nullA change is not done until tsc --noEmit passes at zero errors on the project's actual tsconfig — not a relaxed one.
any shortcut table
Before opening no-any-playbook.md, try the quick map:
| Situation | Wrong | Right |
|---|---|---|
| Unknown shape | any | unknown + narrow (type guard or Zod) |
| Arbitrary function | (...args: any[]) => any | (...args: readonly unknown[]) => unknown |
| Arbitrary object | any / object / {} | Record<string, unknown> or concrete interface |
JSON.parse output | JSON.parse(s) as Foo | FooSchema.parse(JSON.parse(s)) |
| Untyped third-party | declare module "x" with any | narrow .d.ts with unknown + used methods |
| Generic constraint | <T extends any> | <T> (no constraint) |
| React event | (e: any) => … | React.ChangeEvent<HTMLInputElement> |
catch clause | catch (e: any) | catch (e: unknown) then narrow |
| Incompatible bridge | as any | as unknown as T (commented boundary) |
unknown is the universal safe replacement — it forces narrowing.
Anti-patterns — reject on sight
anyin any form.// @ts-ignorewithout// @ts-expect-error+ description + expiry.asthat widens or crosses unrelated types without going throughunknown.Function— use a specific signature.Object/{}as "any object" —{}means "anything except null/undefined".enum— useas constobject + literal union.namespace X {}— use modules.- Optional
x?: Twhen you meanx: T | undefined(they differ underexactOptionalPropertyTypes). throwin a function whose signature doesn't acknowledge failure.- Deep barrel files re-exporting hundreds of symbols.
- Return-type annotations on every internal function.
Detect every any in a project
npx tsc --noEmit --noImplicitAny --strict
grep -rEn ':\s*any(\s|[,;\)\]>]|$)|<any>|as any|any\[\]|Record<[^,]+,\s*any>' --include='*.ts' --include='*.tsx' --include='*.mts' --include='*.cts' src/Communication
When correcting typed code, show the diff: current type → corrected type → the class of bug the corrected type now prevents that the old one missed. Never rewrite silently.
The goal is not TypeScript that compiles. The goal is TypeScript that makes the class of bug you just prevented impossible for the next person on the codebase.
Advanced Type Patterns
1. Branded & Flavored Types
Problem: UserId and OrderId are both string structurally. processOrder(userId) compiles silently.
Variant A — unique symbol brand (strongest, top-level only)
declare const brand: unique symbol must appear at the module top level, not inside a function or block.
// branding.ts
declare const brand: unique symbol;
export type Brand<T, B> = T & { readonly [brand]: B };
// user-id.ts
import type { Brand } from "./branding";
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
// Constructor — only way to produce one
export const UserId = (raw: string): UserId => {
if (!/^usr_[a-z0-9]{24}$/.test(raw)) throw new Error(`Invalid UserId: ${raw}`);
return raw as UserId;
};
declare function processOrder(id: OrderId): void;
const u = UserId("usr_abc123…");
processOrder(u); // ERROR — UserId is not OrderId
processOrder("random"); // ERROR — string is not OrderIdVariant B — string-literal tag (works anywhere)
When declare const can't appear (inside a function body, test file, quick prototype):
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;Trade-off: __brand is a real property name, technically forgeable. Rare in practice. Variant A is strictly safer; Variant B strictly simpler.
Flavored Types (structural, lighter touch)
type Flavor<T, F> = T & { readonly _flavor?: F };
type UserId = Flavor<string, "UserId">; // plain strings assignable to UserId,
type OrderId = Flavor<string, "OrderId">; // but UserId not assignable to OrderIdFlavored is a migration tool; branded is the destination.
When to brand
- Any domain primitive with an invariant (regex, positive integer, normalized email).
- Any ID where swapping two same-shape IDs is a realistic bug.
- Money / currency / units.
USDandEURare bothnumberstructurally. - Values that have been validated and shouldn't be re-validated (
ValidatedEmailvsRawEmail).
2. Phantom Types
Track a state in the type without changing runtime representation.
declare const state: unique symbol;
type State<S> = { readonly [state]: S };
type Connection<S extends "open" | "closed"> = {
readonly url: string;
} & State<S>;
declare function open(url: string): Connection<"open">;
declare function close(c: Connection<"open">): Connection<"closed">;
declare function query(c: Connection<"open">, sql: string): Promise<unknown>;
const c1 = open("postgres://…");
await query(c1, "SELECT 1"); // OK
const c2 = close(c1);
await query(c2, "SELECT 1"); // ERROR — Connection<"closed"> is not Connection<"open">Use for: session/transaction states, form states (dirty/submitting/submitted), builder states, units of measurement.
3. Discriminated Unions (done right)
A DU is a sum type with a literal tag.
type Shape =
| { readonly kind: "circle"; readonly radius: number }
| { readonly kind: "rect"; readonly w: number; readonly h: number }
| { readonly kind: "triangle"; readonly base: number; readonly height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.w * s.h;
case "triangle": return 0.5 * s.base * s.height;
default: return assertNever(s);
}
}Rules:
1. The tag is a literal (not string). kind: "circle", not kind: string. 2. Pick one tag name and stick with it. kind, type, _tag all fine. Inconsistency hurts. 3. Never make a member optional within a variant — if optional, it's a different variant. 4. Readonly by default. 5. Always exhaust.
Common mistake — boolean flags:
// WRONG — allows { ok: true, error: "?" } and { ok: false }
interface Result { ok: boolean; data?: User; error?: string; }
// RIGHT — only valid shapes are constructible
type Result =
| { readonly ok: true; readonly data: User }
| { readonly ok: false; readonly error: string };4. Exhaustiveness with never
function assertNever(x: never, message = "Unreachable"): never {
throw new Error(`${message}: ${JSON.stringify(x)}`);
}
switch (action.kind) {
case "A": return handleA(action);
case "B": return handleB(action);
default: return assertNever(action);
}Adding variant "C" without handling it makes the default receive { kind: "C" }, which is not assignable to never → compile error.
Subtleties:
assertNevermust be called, not just declared.default: throw new Error("unreachable")gives no check.- Use
@typescript-eslint/switch-exhaustiveness-checkto catch missingdefaultentirely.
Exhaustive if/else:
if (action.kind === "A") handleA(action);
else if (action.kind === "B") handleB(action);
else assertNever(action);5. Type-Level State Machines
Combine phantom types and discriminated unions to encode transitions.
type Idle = { readonly state: "idle" };
type Loading = { readonly state: "loading"; readonly requestId: string };
type Success = { readonly state: "success"; readonly data: User };
type Failure = { readonly state: "failure"; readonly error: Error };
type FetchState = Idle | Loading | Success | Failure;
type Event =
| { readonly type: "FETCH"; readonly requestId: string }
| { readonly type: "RESOLVE"; readonly data: User }
| { readonly type: "REJECT"; readonly error: Error }
| { readonly type: "RESET" };
type Transition<S extends FetchState, E extends Event> =
S extends Idle ? E extends { type: "FETCH" } ? Loading : S :
S extends Loading ? E extends { type: "RESOLVE" } ? Success :
E extends { type: "REJECT" } ? Failure : S :
S extends Success | Failure ? E extends { type: "RESET" } ? Idle : S :
never;
declare function transition<S extends FetchState, E extends Event>(
state: S, event: E
): Transition<S, E>;
const s0: Idle = { state: "idle" };
const s1 = transition(s0, { type: "FETCH", requestId: "r1" }); // Loading
const s2 = transition(s1, { type: "RESOLVE", data: user }); // SuccessFor industrial-strength state machines, use XState — same pattern with library ergonomics + visualization.
6. Template Literal Types — practical uses
Route typing:
type ParamNames<Path extends string, Acc extends string = never> =
Path extends `${string}:${infer P}/${infer R}` ? ParamNames<`/${R}`, Acc | P> :
Path extends `${string}:${infer P}` ? Acc | P :
Acc;
type ParamsOf<Path extends string> = { readonly [K in ParamNames<Path>]: string };
type UserParams = ParamsOf<"/users/:id">; // { id: string }
type ItemParams = ParamsOf<"/orders/:orderId/items/:itemId">; // { orderId: string; itemId: string }Note: keyof Record<string, never> is string, not never. Accumulate names into a union first (ParamNames), then build the shape (ParamsOf).
Typed CSS custom properties:
type CSSVar<Name extends string> = `var(--${Name})`;
declare function css<N extends string>(vars: readonly N[]): { [K in N as `--${K}`]: string };Event names:
type EventOf<T> = `${string & keyof T}:changed`;
// For T = { name: string; age: number }, EventOf<T> = "name:changed" | "age:changed"Stop point: if you need more than ~3 levels of infer, write a runtime parser + branded types. The compiler recursion limit (default 50) is a wall.
7. Conditional Types & infer: depth control
Distributive by default:
type ToArray<T> = T extends unknown ? T[] : never;
type X = ToArray<string | number>; // string[] | number[]Wrap in tuple for non-distributive:
type ToArrayNonDistributive<T> = [T] extends [unknown] ? T[] : never;
type Y = ToArrayNonDistributive<string | number>; // (string | number)[]Recursion depth — TS limit is ~50. "Type instantiation is excessively deep and possibly infinite" → add a depth counter.
Bad (uncontrolled):
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;Good (depth-limited):
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type Flatten<T, D extends number = 9> =
D extends 0 ? T :
T extends readonly (infer U)[] ? Flatten<U, Prev[D]> : T;`infer extends`:
type ExtractNumber<T> = T extends `${infer N extends number}` ? N : never;
type A = ExtractNumber<"42">; // 42
type B = ExtractNumber<"abc">; // never8. Higher-Kinded Types (simulation)
TS doesn't support HKTs natively. For 99% of work you don't need them. When you do, simulate via defunctionalization:
interface HKT { readonly _A: unknown; readonly type: unknown }
type Apply<F extends HKT, A> = (F & { readonly _A: A })["type"];
interface ArrayHKT extends HKT { readonly type: readonly this["_A"][] }
interface PromiseHKT extends HKT { readonly type: Promise<this["_A"]> }
type X = Apply<ArrayHKT, number>; // readonly number[]
type Y = Apply<PromiseHKT, string>; // Promise<string>
interface Functor<F extends HKT> {
readonly map: <A, B>(fa: Apply<F, A>, f: (a: A) => B) => Apply<F, B>;
}If you find yourself here, consider Effect — already built this ecosystem.
9. enum — why not, what to use instead
Problems:
- Numeric enums reverse-map at runtime (
Color.Red === 0andColor[0] === "Red"). const enumbreaks underisolatedModulesand many bundlers.- String enums emit an object, don't tree-shake, don't spread or iterate naturally.
Replacement:
const Color = {
Red: "red",
Green: "green",
Blue: "blue",
} as const;
type Color = typeof Color[keyof typeof Color]; // "red" | "green" | "blue"
function paint(c: Color) { /* ... */ }
paint(Color.Red);
paint("red"); // also valid — string literals matchZero runtime overhead beyond the object, trivial tree-shaking, spreadable, iterable.
10. Utility Types You Should Build Yourself
Keep a src/types/utils.ts:
/** Deep readonly */
export type DeepReadonly<T> =
T extends (...args: readonly unknown[]) => unknown ? T :
T extends readonly (infer U)[] ? readonly DeepReadonly<U>[] :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
/** Deep partial — useful for config merging */
export type DeepPartial<T> =
T extends (...args: readonly unknown[]) => unknown ? T :
T extends readonly (infer U)[] ? readonly DeepPartial<U>[] :
T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
T;
/** Non-empty array */
export type NonEmptyArray<T> = readonly [T, ...T[]];
/** Exactly one of */
export type ExactlyOne<T, K extends keyof T = keyof T> =
K extends keyof T
? Required<Pick<T, K>> & { readonly [P in Exclude<keyof T, K>]?: never }
: never;
/** Prettify — forces resolution of intersections for nicer hover */
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
/** Tagged — explicit nominal typing */
export type Tagged<T, Tag extends string> = T & { readonly __tag: Tag };
/** UnionToIntersection */
export type UnionToIntersection<U> =
(U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
/** AssertEqual — compile-time invariant */
export type AssertEqual<T, U> =
(<X>() => X extends T ? 1 : 2) extends (<X>() => X extends U ? 1 : 2) ? true : false;
/** Split a literal string type */
export type Split<S extends string, D extends string> =
string extends S ? readonly string[] :
S extends "" ? readonly [] :
S extends `${infer Head}${D}${infer Tail}` ? readonly [Head, ...Split<Tail, D>] :
readonly [S];Every wildcard uses unknown. Zero any.
API Design — Type-Safe Libraries & Clients
Designing library-quality APIs (event emitters, HTTP clients, query builders, form DSLs) where the caller gets full IDE autocomplete and compile-time errors on misuse. Zero any throughout.
1. Type-safe event emitter
The caller writes an event map; the emitter enforces matching keys and payloads on emit/on.
type EventMap = {
"user:created": { readonly id: string; readonly email: string };
"user:updated": { readonly id: string; readonly changes: Readonly<Record<string, unknown>> };
"user:deleted": { readonly id: string };
"app:ready": void; // no payload
};
const bus = new TypedEmitter<EventMap>();
bus.on("user:created", ({ id, email }) => { /* typed */ });
bus.emit("user:created", { id: "u_1", email: "a@b.c" }); // OK
bus.emit("user:created", { id: "u_1" }); // ERROR — missing email
bus.emit("app:ready"); // OK — void payloadImplementation: the obvious approach — one conditional-typed signature emit<K>(...args: [M[K]] extends [void] ? [K] : [K, M[K]]) — fails. Conditional types do not simplify when the input is still a bare generic, and overload resolution commits to a shape before K is concrete.
Use explicit overloads instead:
type Listener<P> = (payload: P) => void;
type VoidEvents<M> = { [K in keyof M]: M[K] extends void ? K : never }[keyof M];
type PayloadEvents<M> = { [K in keyof M]: M[K] extends void ? never : K }[keyof M];
export class TypedEmitter<M extends Readonly<Record<string, unknown>>> {
readonly #listeners = new Map<keyof M, Set<Listener<unknown>>>();
on<K extends keyof M>(event: K, listener: Listener<M[K]>): () => void {
const set = this.#listeners.get(event) ?? new Set();
set.add(listener as Listener<unknown>);
this.#listeners.set(event, set);
return (): void => { set.delete(listener as Listener<unknown>); };
}
emit<K extends VoidEvents<M>>(event: K): void;
emit<K extends PayloadEvents<M>>(event: K, payload: M[K]): void;
emit(event: keyof M, payload?: unknown): void {
for (const l of this.#listeners.get(event) ?? []) (l as Listener<unknown>)(payload);
}
}- Private fields (
#listeners) truly private (runtime + types). - Unsubscribe returned from
on— idiomatic and GC-safe. ascasts localized to internal storage; public API fully typed.
2. Type-safe HTTP client
Define endpoints once. Every call site gets autocomplete on path, method, params, body, response. Zod parses responses.
const api = defineClient({
baseUrl: "https://api.example.com",
endpoints: {
getUsers: { method: "GET", path: "/users", response: z.array(UserSchema) },
getUser: { method: "GET", path: "/users/:id", response: UserSchema },
createUser: { method: "POST", path: "/users",
body: z.object({ name: z.string(), email: z.string().email() }),
response: UserSchema },
deleteUser: { method: "DELETE", path: "/users/:id", response: z.void() },
},
});
const users = await api.getUsers();
const user = await api.getUser({ params: { id: "u_1" } });
const fresh = await api.createUser({ body: { name: "A", email: "a@b.c" } });Core implementation:
import { z } from "zod";
type Endpoint = {
readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
readonly path: string;
readonly response: z.ZodTypeAny;
readonly body?: z.ZodTypeAny;
readonly query?: z.ZodTypeAny;
};
type PathParamNames<P extends string, Acc extends string = never> =
P extends `${string}:${infer Param}/${infer Rest}` ? PathParamNames<`/${Rest}`, Acc | Param> :
P extends `${string}:${infer Param}` ? Acc | Param :
Acc;
type HasPathParams<P extends string> = [PathParamNames<P>] extends [never] ? false : true;
type PathParams<P extends string> = { readonly [K in PathParamNames<P>]: string };
type CallArgs<E extends Endpoint> =
(HasPathParams<E["path"]> extends true ? { readonly params: PathParams<E["path"]> } : { readonly params?: undefined })
& (E["body"] extends z.ZodTypeAny ? { readonly body: z.input<E["body"]> } : { readonly body?: undefined })
& (E["query"] extends z.ZodTypeAny ? { readonly query: z.input<E["query"]> } : { readonly query?: undefined });
type ArgsOrNothing<E extends Endpoint> =
HasPathParams<E["path"]> extends true ? [args: CallArgs<E>] :
E["body"] extends z.ZodTypeAny ? [args: CallArgs<E>] :
E["query"] extends z.ZodTypeAny ? [args: CallArgs<E>] :
[];
type Client<E extends Readonly<Record<string, Endpoint>>> = {
readonly [K in keyof E]: (...args: ArgsOrNothing<E[K]>) => Promise<z.output<E[K]["response"]>>;
};
export function defineClient<E extends Readonly<Record<string, Endpoint>>>(
config: { readonly baseUrl: string; readonly endpoints: E; readonly fetch?: typeof fetch }
): Client<E> {
const f = config.fetch ?? fetch;
const result = {} as Record<string, (args?: unknown) => Promise<unknown>>;
for (const [name, ep] of Object.entries(config.endpoints)) {
result[name] = async (args?: unknown) => {
const a = (args ?? {}) as { params?: Record<string, string>; body?: unknown; query?: Record<string, unknown> };
let url = config.baseUrl + ep.path;
if (a.params) for (const [k, v] of Object.entries(a.params)) url = url.replace(`:${k}`, encodeURIComponent(v));
if (a.query) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(a.query)) if (v !== undefined) q.set(k, String(v));
const qs = q.toString();
if (qs) url += `?${qs}`;
}
const response = await f(url, {
method: ep.method,
headers: ep.body ? { "content-type": "application/json" } : undefined,
body: ep.body ? JSON.stringify(a.body) : undefined,
});
if (!response.ok) throw new HTTPError(response.status, await response.text());
const raw: unknown = ep.response._def.typeName === "ZodVoid" ? undefined : await response.json();
return ep.response.parse(raw);
};
}
return result as Client<E>;
}
class HTTPError extends Error {
readonly _tag = "HTTPError" as const;
constructor(readonly status: number, readonly body: string) { super(`HTTP ${status}`); }
}PathParamNamestail-recursively accumulates:namesegments.PathParamsbuilds the shape from that union. Split becausekeyof Record<string, never>isstring, notnever.- Response is parsed, not cast. Shape drift surfaces immediately.
- Internal
ascasts convert dynamic implementation → precise public type.
3. Type-safe builder (required-fields enforcement)
Caller can't call .build() until every required field is set.
interface User { id: string; name: string; email: string; age?: number; }
const u = Builder<User>()
.with("id", "u_1")
.with("name", "A")
.with("email", "a@b.c")
.build(); // OK
Builder<User>().with("id", "u_1").build(); // ERROR — missing name, emailImplementation:
type OptionalKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? K : never }[keyof T];
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
class BuilderImpl<T extends object, Set extends keyof T = never> {
readonly #value = {} as Partial<T>;
with<K extends keyof T>(key: K, value: T[K]): BuilderImpl<T, Set | K> {
(this.#value as Record<PropertyKey, unknown>)[key as PropertyKey] = value;
return this as unknown as BuilderImpl<T, Set | K>;
}
build(this: RequiredKeys<T> extends Set ? this : never): T {
return this.#value as T;
}
}
export function Builder<T extends object>(): BuilderImpl<T> {
return new BuilderImpl<T>();
}this: RequiredKeys<T> extends Set ? this : never on build — when Set doesn't cover required keys, this resolves to never → .build() uncallable.
4. Type-safe dependency injection
const DB = Tag<DB>("DB");
const Logger = Tag<Logger>("Logger");
const container = Container.empty()
.provide(DB, { type: "postgres", connectionString: "…" })
.provide(Logger, console);
const db: DB = container.get(DB); // typed as DB
const x = container.get(Redis); // ERROR — Redis not in containerSketch:
const tagSymbol = Symbol("Tag");
export type Tag<T> = { readonly [tagSymbol]: unique symbol & { readonly _service: T }; readonly name: string };
export function Tag<T>(name: string): Tag<T> {
return { [tagSymbol]: Symbol(name) as Tag<T>[typeof tagSymbol], name } as Tag<T>;
}
class Container<R extends Record<symbol, unknown>> {
private constructor(private readonly map: R) {}
static empty(): Container<Record<symbol, never>> { return new Container({}); }
provide<T, Tg extends Tag<T>>(tag: Tg, value: T): Container<R & Record<Tg[typeof tagSymbol], T>> {
return new Container({ ...this.map, [tag[tagSymbol]]: value } as never);
}
get<T, Tg extends Tag<T>>(tag: Tg & (Tg[typeof tagSymbol] extends keyof R ? unknown : never)): T {
return this.map[tag[tagSymbol] as keyof R] as T;
}
}get's conditional resolves to never when the tag's symbol isn't a key of R → retrieval of an unregistered tag is a compile error.
For production, use Effect's Context — implements this with full dependency-graph resolution.
5. Type-safe route registry
const routes = {
home: "/",
users: "/users",
user: "/users/:id",
item: "/users/:userId/items/:itemId",
} as const;
type Routes = typeof routes;
type RouteName = keyof Routes;
type ParamNames<Path extends string, Acc extends string = never> =
Path extends `${string}:${infer P}/${infer R}` ? ParamNames<`/${R}`, Acc | P> :
Path extends `${string}:${infer P}` ? Acc | P :
Acc;
type ParamsOf<Path extends string> = { readonly [K in ParamNames<Path>]: string };
type NavArgs<N extends RouteName> =
[ParamNames<Routes[N]>] extends [never]
? [name: N]
: [name: N, params: ParamsOf<Routes[N]>];
function navigate<N extends RouteName>(...args: NavArgs<N>): void {
const [name, params] = args as readonly [N, Record<string, string>?];
let path: string = routes[name];
if (params) for (const [k, v] of Object.entries(params)) path = path.replace(`:${k}`, v);
window.location.assign(path);
}
navigate("home");
navigate("user", { id: "u_1" });
navigate("item", { userId: "u_1", itemId: "i_1" });
navigate("user"); // ERROR — missing params arg
navigate("user", { userId: "u_1" }); // ERROR — wrong param nameLesson: keyof Record<string, never> is string, not never. To check "is this type empty?" at the type level, check the union ([ParamNames<P>] extends [never]) instead of keyof.
Cross-cutting lessons
1. The public signature is the product. Implementation can have localized as unknown as T; users' code must never need one. 2. Template literal types turn stringly-typed APIs into typed ones (paths, event names, CSS vars, params). 3. `as const` / `const` type parameters preserve the literal shape callers provide. Without them you widen to string[]. 4. Parse untyped inputs at the boundary. Typed-looking APIs without a parser at the edge are a lie. 5. Public function return types are annotated, not inferred — API stability + isolatedDeclarations. 6. Conditional types in parameter positions often break. A signature like fn<K>(...args: Cond<K> extends X ? [A] : [A, B]) appears to work in tests but deadlocks at real call sites — the compiler can't simplify Cond<K> until K is pinned, and K is pinned by the argument shape. Switch to overloads. Conditionals belong in utility types, not in function signatures driving inference. 7. `satisfies` alone does not preserve literals. It verifies without widening but doesn't narrow. Pair with as const for pinned literals. 8. Generic parameters interact with inference in surprising ways. When two sites in the same signature inform a generic, the later site wins by default — sometimes wrong. Use NoInfer<T> to nominate the canonical site.
Code Review Checklist
Organized by severity. Every Blocker must be addressed before merge.
Blockers (never merge)
any in any form
- [ ] No
anyin signatures, generics, type assertions, declarations, or comments. - [ ] No
// @ts-ignore— must be// @ts-expect-error+ description. - [ ] No implicit
any(verifynoImplicitAny: true, indirectly viastrict: true). - [ ] No
Record<string, any>,(...args: any[]),as any,<any>generic arguments. - [ ] No reliance on
anyfrom missingts-reset.
Runtime safety at boundaries
- [ ] Every HTTP response body is parsed, not cast.
- [ ] Every
JSON.parseresult is parsed. - [ ] Every form submission goes through a schema.
- [ ] Every env-var access goes through a parsed
envobject. - [ ] Every LLM response is parsed before use.
- [ ] Every
localStorage/sessionStorageread is parsed.
Error handling
- [ ] Functions with expected failures return
Resultor a discriminated union, notthrow. - [ ] Every
catchclause isunknown(or narrower) and narrows before use. - [ ] No
catch {}silent swallows. - [ ] Exhaustive switches have
assertNeverdefaults. - [ ] Promise-returning functions handle rejection paths.
Type system strictness
- [ ]
noUncheckedIndexedAccesson andarr[i]uses are handled. - [ ]
exactOptionalPropertyTypeson; optional fields aren't used whereT | undefinedwas meant. - [ ] Discriminated unions use literal tags, not
string. - [ ] No
Function,Object, or{}as types.
Module hygiene
- [ ]
import typefor type-only imports. - [ ] No circular imports introduced.
- [ ] No new barrel files re-exporting large numbers of symbols.
- [ ] No ambient type augmentation of third-party modules without explanation.
Strong preferences (request changes unless justified)
Immutability
- [ ] Every data-type field is
readonly. - [ ] Every array parameter is
ReadonlyArray<T>orreadonly T[]. - [ ] Literal data uses
as const. - [ ] Helpers don't mutate arguments unless that's their stated purpose.
Domain modeling
- [ ] IDs, money, units, validated primitives are branded.
- [ ] Functions accept the narrowest type they need.
- [ ] Illegal states unrepresentable (no
{ loading: boolean; error: string | null; data: User | null }— use a DU). - [ ] Optional properties mean "may be absent";
T | undefinedmeans "present, may be undefined".
Signatures
- [ ] Public function return types explicit.
- [ ] Private function return types inferred.
- [ ] Generic parameter names meaningful (
Tfor one;TValue,TKeyfor multiple). - [ ]
consttype parameters on APIs that depend on literal-argument shape. - [ ]
NoInfer<T>where appropriate.
Modern features
- [ ]
satisfiesinstead ofasfor shape-verifying literal data. - [ ]
using/await usingfor disposables (TS 5.2+). - [ ] Enum replaced by
as const+ literal union. - [ ]
@ts-expect-error(not@ts-ignore) with description.
API design
- [ ] Caller cannot reach an invalid state at compile time.
- [ ] Autocomplete works (no
stringwhere a literal union could be). - [ ] Error paths visible in signatures.
- [ ]
AbortSignalaccepted where relevant.
Extra-scrutiny signals
- PR adds
// @ts-expect-error— is the real bug fixed elsewhere? When does this expire? - PR changes a tsconfig flag — especially turning a strict flag off. Why?
- Generic with more than 3 type parameters — usually refactorable.
- Deep conditional types (
X extends Y ? A : B extends C ? D : E) — request a type test. - Zod schema that's
z.any()orz.unknown()for a non-opaque field. as Tassertions — is there a parser that could do this correctly?!non-null assertions (x!) — is there a guard instead?
Things that look wrong but are fine
as conston readonly data — correct, preserves literal types.as unknown as Twith a description — explicit two-step cast.- Empty
interface Extends extends Base {}to rename a type — fine. voidas return type on event handlers — correct.neverin a conditional's false branch — correct, makes the type "exhaustive or error".
Review process
1. Run tsc --noEmit on the PR branch. Must be clean. 2. Run the linter. Must be clean. 3. Run type tests. Must pass. 4. Read the types first, implementation second. If the types are wrong, correct implementation is a coincidence. 5. Grep for any/@ts-ignore/as — each occurrence gets a comment. 6. For any new public API, check for a type test. Request one if the type has branches.
Error Handling
TypeScript has two error mechanisms: throw and return types. The language treats them identically; good codebases don't.
Core rule
- Expected failures (bad user input, network down, permission denied, not found) → encode in the return type. Caller must handle.
- Invariant violations (shouldn't happen, bug) →
throw. Caller cannot usefully handle.
If a signature says (id: string) => User but the function throws on "user not found", the signature lies. Fix it.
Result / Either pattern
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
function parseInteger(s: string): Result<number, "empty" | "not_a_number"> {
if (s === "") return err("empty");
const n = Number(s);
return Number.isFinite(n) && Number.isInteger(n) ? ok(n) : err("not_a_number");
}
const r = parseInteger(input);
if (r.ok) {
useNumber(r.value);
} else {
switch (r.error) {
case "empty": return showEmpty();
case "not_a_number": return showNotANumber();
default: return assertNever(r.error);
}
}Error is a literal union, not string. Caller gets exhaustive matching.
With libraries
`neverthrow` — ergonomic chaining:
import { Result, ok, err, ResultAsync } from "neverthrow";
const user = await ResultAsync.fromPromise(
fetch(`/api/users/${id}`).then(r => r.json()),
() => "network_error" as const,
)
.andThen(raw => fromZod(UserSchema.safeParse(raw), () => "invalid_shape" as const))
.map(u => ({ ...u, normalizedEmail: u.email.toLowerCase() }));`Effect` — industrial-strength effect tracking with typed errors, DI, fiber concurrency.
Typed error hierarchies (for boundary catches)
Some boundaries (top-level handlers, CLI entry points) do use try/catch. Give errors a discriminator:
abstract class TaggedError extends Error {
abstract readonly _tag: string;
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = this.constructor.name;
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends TaggedError { readonly _tag = "NotFoundError" as const; }
class UnauthorizedError extends TaggedError { readonly _tag = "UnauthorizedError" as const; }
class ValidationError extends TaggedError {
readonly _tag = "ValidationError" as const;
constructor(message: string, public readonly issues: readonly z.ZodIssue[]) { super(message); }
}
type AppError = NotFoundError | UnauthorizedError | ValidationError;
function toResponse(e: unknown): Response {
if (!(e instanceof TaggedError)) {
logger.error("Unknown error", { e });
return new Response("Internal error", { status: 500 });
}
switch (e._tag) {
case "NotFoundError": return new Response(e.message, { status: 404 });
case "UnauthorizedError": return new Response(e.message, { status: 401 });
case "ValidationError": return Response.json({ issues: e.issues }, { status: 400 });
default: {
const _: never = e;
return new Response("Internal error", { status: 500 });
}
}
}Async error handling
`await` + `try/catch` is fine, but know the pitfalls:
// Floating promise — silently lost error
users.forEach(async u => { await save(u); });
// Fixed
await Promise.all(users.map(u => save(u)));Enable @typescript-eslint/no-floating-promises. Catches this entire class.
`Promise.allSettled` when partial failure is OK:
const results = await Promise.allSettled(users.map(fetchDetail));
const details = results.flatMap(r => r.status === "fulfilled" ? [r.value] : []);`AbortSignal` propagation — any async function doing I/O accepts an optional AbortSignal:
async function fetchUser(id: string, signal?: AbortSignal): Promise<User> {
const response = await fetch(`/api/users/${id}`, { signal });
return UserSchema.parse(await response.json());
}Cancellation that doesn't propagate leaks work.
throw is for bugs
Acceptable — asserting an invariant the caller has established:
function getOrThrow<T>(map: ReadonlyMap<string, T>, key: string): T {
const v = map.get(key);
if (v === undefined) throw new Error(`Invariant: expected ${key} in map`);
return v;
}Not acceptable — expected failure via wrong mechanism:
function getUser(id: string): User {
const user = db.findUser(id);
if (!user) throw new Error("User not found"); // wrong
return user;
}Encode it:
function getUser(id: UserId): Result<User, "not_found"> { /* ... */ }
// or
function getUser(id: UserId): User | null { /* ... */ }Don't swallow
try { riskyThing(); } catch {} // 🚫 silent
try { riskyThing(); } catch (e) { console.log(e); } // 🚫 eaten in prod
try { riskyThing(); } catch (e: unknown) {
logger.error("riskyThing failed", { e });
throw e;
} // ✅Either handle and recover, or log and rethrow. Never consume silently.
Modern TS 5.x Features — current idiom
Not optional — this is the idiom. For projects on TS <5.0, suggest upgrading before working around their absence.
satisfies (TS 4.9+) — verify without widening
type Config = Record<string, { url: string; timeout: number }>;
// `as Config` loses literal types
const cfg1 = { api: { url: "/api", timeout: 5000 } } as Config;
// cfg1.api.url: string — literal lost
// `satisfies` alone is NOT enough — the literals have already widened via contextual inference
const cfg2 = { api: { url: "/api", timeout: 5000 } } satisfies Config;
// cfg2.api.url: string — still widened
// Correct — `as const` pins literals; `satisfies` verifies shape
const cfg3 = { api: { url: "/api", timeout: 5000 } } as const satisfies Config;
// cfg3.api.url: "/api"Rule: satisfies is a type check, not narrowing. To preserve literals, combine with as const. as const satisfies X is the paired idiom for pinned literals + shape verification.
const type parameters (TS 5.0+)
function pick<const T extends readonly string[]>(keys: T) { return keys; }
const a = pick(["x", "y"]); // readonly ["x", "y"] — literal tuple
// without `const`: readonly string[] — widenedEssential for builder DSLs, route definitions, anything where the literal shape of the argument drives the return type.
NoInfer<T> (TS 5.4+)
function createStore<S>(options: {
initial: S;
reducer: (s: NoInfer<S>, a: unknown) => NoInfer<S>;
}) { /* ... */ }
// S is determined ONLY by `initial`, not by the reducer's parameter.Use whenever a generic has multiple sites and you want exactly one to drive inference.
Variance modifiers in/out (TS 4.7+)
interface Producer<out T> { readonly produce: () => T; } // covariant
interface Consumer<in T> { readonly consume: (x: T) => void; } // contravariant
interface IO<in out T> {
readonly read: () => T;
readonly write: (x: T) => void;
} // invariant- Output-only →
out T. - Input-only →
in T. - Both →
in out T.
Documents intent. Does not change runtime behavior.
using / await using (TS 5.2+)
function openFile(path: string) {
const fd = fs.openSync(path, "r");
return { fd, [Symbol.dispose]() { fs.closeSync(fd); } };
}
function process() {
using file = openFile("./data.txt"); // auto-disposed on scope exit
// ...
}
async function processAsync() {
await using db = await openDb(); // Symbol.asyncDispose
// ...
}Replaces try { ... } finally { close(); } for resource management. Works with bundlers supporting "Explicit Resource Management" (esbuild, swc, modern tsc).
isolatedDeclarations (TS 5.5+, libraries only)
Every exported function/class/const needs an explicit type annotation. Makes .d.ts generation a pure syntactic transform (10-100× faster). Apps: leave off. Libraries: turn on.
verbatimModuleSyntax (TS 5.0+)
import/export syntax preserved verbatim. Forces import type explicitly. Non-negotiable for ESM. Replaces importsNotUsedAsValues and preserveValueImports.
---
ts-reset — fix TypeScript's built-in lies
Install `@total-typescript/ts-reset` on every project:
npm i -D @total-typescript/ts-resetCreate src/types/reset.d.ts:
import "@total-typescript/ts-reset";Corrects (non-exhaustive):
JSON.parse(...)→unknowninstead ofany.response.json()→unknowninstead ofany.Array.isArray(x)onreadonly T[]narrows correctly (default widens toany[])..filter(Boolean)actually removesnull/undefinedfrom the type.localStorage.getItem()returnsstring | nullcorrectly.
If it's not installed, add it.
The No-any Playbook
Every occurrence of any has a correct replacement. Exhaustive list follows.
any is contagious (propagates through inference from a single entry point) and silent (no compiler error). unknown is the inverse: it refuses to be used without narrowing.
Shortcut decision table
| Situation | Wrong | Right |
|---|---|---|
| Unknown shape | any | unknown + narrow (type guard or Zod) |
| Arbitrary function | (...args: any[]) => any | (...args: readonly unknown[]) => unknown |
| Arbitrary object | any / object / {} | Record<string, unknown> or concrete interface |
JSON.parse output | JSON.parse(s) as Foo | FooSchema.parse(JSON.parse(s)) |
| Untyped third-party | declare module "x" with any | narrow .d.ts with unknown + used methods |
| Generic constraint | <T extends any> | <T> (no constraint) |
| React event | (e: any) => … | React.ChangeEvent<HTMLInputElement> |
catch clause | catch (e: any) | catch (e: unknown) then narrow |
| Incompatible bridge | as any | as unknown as T (commented boundary) |
---
1. "I don't know the shape of this value"
Untyped input (JSON, form data, IPC message, LLM output).
Wrong:
function handle(data: any) {
return data.user.email.toLowerCase();
}Right — runtime validation at the boundary (preferred):
import { z } from "zod";
const PayloadSchema = z.object({
user: z.object({ email: z.string().email() }),
});
type Payload = z.infer<typeof PayloadSchema>;
function handle(raw: unknown): string {
const data = PayloadSchema.parse(raw);
return data.user.email.toLowerCase();
}Right — manual narrowing when no schema lib available:
function handle(data: unknown): string {
if (
typeof data === "object" && data !== null &&
"user" in data &&
typeof (data as Record<string, unknown>).user === "object" &&
(data as Record<string, unknown>).user !== null &&
"email" in ((data as { user: Record<string, unknown> }).user) &&
typeof ((data as { user: { email: unknown } }).user.email) === "string"
) {
return ((data as { user: { email: string } }).user.email).toLowerCase();
}
throw new Error("Invalid payload");
}Bugs prevented: crashing on undefined/null/wrong-type; silently accepting { user: { email: 42 } } and calling 42.toLowerCase() downstream.
2. "It's a function, I don't care about its signature"
Higher-order code: middleware, decorators, wrappers.
Wrong:
function once(fn: (...args: any[]) => any) { /* ... */ }
function memoize<F extends (...args: any[]) => any>(fn: F): F { /* ... */ }Right:
// Don't care at all — still types the wrapper safely
function once(fn: (...args: readonly unknown[]) => unknown) { /* ... */ }
// Preserve the wrapped signature through the wrapper
function memoize<Args extends readonly unknown[], R>(
fn: (...args: Args) => R
): (...args: Args) => R { /* ... */ }
const add = (a: number, b: number) => a + b;
const memoized = memoize(add); // (a: number, b: number) => number — preservedBugs prevented: wrappers silently erasing the wrapped function's types, propagating any to every call site.
3. "It's some object"
Wrong:
function log(context: any) { /* ... */ }
function log(context: object) { /* ... */ } // object includes arrays, functions
function log(context: {}) { /* ... */ } // worst — "anything except null/undefined"Right:
function log(context: Record<string, unknown>) { /* dictionary */ }
function log(context: { userId: string; requestId: string }) { /* known shape */ }
type Json = string | number | boolean | null | readonly Json[] | { readonly [k: string]: Json };
function log(context: Record<string, Json>) { /* JSON-serializable */ }The {} footgun: const x: {} = 5 is valid TypeScript. Use Record<string, never> for "truly empty" or Record<string, unknown> for "unknown contents".
4. "Third-party library has no types"
Wrong:
declare module "untyped-pkg";
declare module "untyped-pkg" { const x: any; export = x; }Right — write the minimal typed surface you actually use:
// types/untyped-pkg.d.ts
declare module "untyped-pkg" {
export function connect(url: string): Promise<Connection>;
export interface Connection {
query(sql: string, params?: readonly unknown[]): Promise<readonly unknown[]>;
close(): Promise<void>;
}
}If you genuinely don't know what the library returns, type as unknown so consumers must narrow. Check @types/* on DefinitelyTyped first. Check the package for bundled .d.ts. Shim only when neither exists.
5. Generic constraints
Wrong:
function identity<T extends any>(x: T): T { return x; }
function merge<T extends any, U extends any>(a: T, b: U): T & U { return { ...a, ...b }; }Right:
function identity<T>(x: T): T { return x; }
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}<T extends any> is indistinguishable from <T>. If you wrote extends any, you had no constraint in mind.
6. catch clauses
Wrong:
try { /* ... */ } catch (e: any) { console.log(e.message); }Right — with `useUnknownInCatchVariables: true` (default in strict):
try { /* ... */ } catch (e: unknown) {
if (e instanceof Error) console.log(e.message);
else console.log("Unknown error", e);
}Right — typed error hierarchy:
class ValidationError extends Error { readonly _tag = "ValidationError" as const; }
class NetworkError extends Error { readonly _tag = "NetworkError" as const; }
try { /* ... */ } catch (e: unknown) {
if (e instanceof ValidationError) { /* handle */ }
else if (e instanceof NetworkError) { /* handle */ }
else throw e;
}Better still: don't throw for expected failures. Return Result<T, E> — see error-handling.md.
7. React event handlers, refs, component props
Wrong:
const onChange = (e: any) => setValue(e.target.value);
const ref: any = useRef(null);
const Button = (props: any) => <button {...props} />;Right:
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);
const ref = useRef<HTMLInputElement>(null);
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & { variant?: "primary" | "ghost" };
const Button = ({ variant = "primary", ...rest }: ButtonProps) => <button {...rest} />;8. as any to bridge incompatible types
Wrong:
const user = apiResponse as any as User;Right — prefer to fix the source:
const user = UserSchema.parse(apiResponse);Right — if you truly must assert, go through `unknown` and comment:
// Legacy API returns Date as epoch ms; converted before this point.
// Remove when migrated to v2 (TICKET-123).
const user = apiResponse as unknown as User;x as unknown as Y forces re-typing the lie — reviewers see it. as any hides in the middle of expressions.
9. Record with unknown-ish values
Wrong:
type Config = Record<string, any>;Right — enumerate known keys, escape-hatch the rest if needed:
interface Config {
readonly apiUrl: string;
readonly timeout: number;
readonly flags: Record<string, boolean>;
}Right — if genuinely dynamic:
type Config = Record<string, unknown>;10. Array of unknown shapes
Wrong:
const items: any[] = [];
function first(xs: any[]): any { return xs[0]; }Right:
const items: readonly unknown[] = [];
function first<T>(xs: readonly T[]): T | undefined { return xs[0]; }With noUncheckedIndexedAccess, xs[0] is correctly T | undefined.
11. Express/Fastify/Koa handlers — req.body, req.query, req.params
These arrive as any by default. Always parse.
Wrong:
app.post("/users", (req, res) => {
const { name, email } = req.body; // both `any`
createUser(name, email);
});Right:
const CreateUserBody = z.object({ name: z.string().min(1), email: z.string().email() });
app.post("/users", (req, res) => {
const parsed = CreateUserBody.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.flatten() });
createUser(parsed.data.name, parsed.data.email);
});Or use a framework with built-in schema support (Fastify + fastify-type-provider-zod, tRPC, Hono + @hono/zod-validator, Elysia).
12. Redux/Zustand/XState reducers that "accept any action"
Wrong:
function reducer(state: State, action: any): State { /* ... */ }Right — discriminated union of actions + exhaustive `never`:
type Action =
| { type: "INCREMENT"; by: number }
| { type: "RESET" }
| { type: "SET_USER"; user: User };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INCREMENT": return { ...state, count: state.count + action.by };
case "RESET": return { ...state, count: 0 };
case "SET_USER": return { ...state, user: action.user };
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}Adding a new action type and forgetting to handle it becomes a compile error.
13. @ts-ignore — ban, use @ts-expect-error
Wrong:
// @ts-ignore
foo.bar.baz();Right:
// @ts-expect-error — `bar` exists only at runtime via monkey-patch in legacy-shim.ts.
// Remove when legacy-shim is deleted (TICKET-456).
foo.bar.baz();@ts-expect-error fails the build when the underlying issue is fixed, so the comment self-removes.
{ "rules": { "@typescript-eslint/ban-ts-comment": ["error", {
"ts-ignore": true,
"ts-expect-error": "allow-with-description",
"minimumDescriptionLength": 10
}]}}14. Sneaky anys — the ones you miss
Easy to overlook even with strict: true:
Array.isArray(x)onreadonly T[]narrows toany[]by default → install@total-typescript/ts-reset.JSON.parse(s)returnsany→ same.response.json()returnsany→ same.await fetch(...).then(r => r.json())propagatesany→.then(r => r.json()).then(Schema.parse).new Map()without type args isMap<any, any>→ parameterize or rely on first-insert inference.Object.keys(x)returnsstring[], not(keyof typeof x)[](intentional for soundness). Typed helper:
function keysOf<T extends object>(obj: T): readonly (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}15. Enforcement — make any impossible to merge
{
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error"
}Biome:
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "error" } } } }CI grep guard:
grep -rE ':\s*any(\s|[,;\)\]>]|$)|<any>|\bas any\b|any\[\]' src/ && exit 1 || true16. The "one legitimate any" — why you still don't need it
unknown works in every wrapper case and forces callers to narrow. The only real exception is the internal implementation of a type-level utility where the runtime is a lie (e.g. an overloaded function body TS can't follow). Even there, use as unknown as TReturn with a comment, not any.
If you believe you've found a necessary any, bring it to review. Top engineering teams write millions of lines without it. The exception does not exist.
TypeScript Performance
tsc slow? Follow this sequence. Do not randomly toggle flags.
Diagnosis — always measure first
# Baseline numbers
npx tsc --noEmit --extendedDiagnostics 2>&1 \
| grep -E "Files:|Lines of Library:|Lines of Definitions:|Lines of TypeScript:|Types:|Instantiations:|Check time|Total time"
# Detailed trace
npx tsc --noEmit --generateTrace trace-out
# Open about:tracing in Chromium and load trace-out/trace.json
# Or:
npx @typescript/analyze-trace trace-outRead Instantiations:. Large projects have millions; over ~50 million usually means a hot loop in types.
Project-level fixes (biggest wins)
1. `skipLibCheck: true` — on any project with 20+ dependencies, cuts type-check time substantially. 2. Project references — a 20-package monorepo without them is ~10× slower. 3. `incremental: true` — writes a .tsbuildinfo cache:
{ "compilerOptions": { "incremental": true, "tsBuildInfoFile": "./.cache/.tsbuildinfo" } }4. Precise `include` / `exclude`:
{
"include": ["src/**/*", "types/**/*"],
"exclude": ["node_modules", "dist", ".next", "coverage", ".cache"]
}5. Don't use `tsc` as a bundler's type checker in the same process. Run tsc --noEmit in CI and for IDE feedback. Let esbuild/swc/rspack transpile — 50-100× faster.
Code-level fixes
Hot type: pick one representation. Don't compute the same type three different ways. Cache with an alias:
// Re-derives on every use
function a(x: { readonly [K in keyof User]: User[K] | null }) { /* ... */ }
// Computed once
type Nullable<T> = { readonly [K in keyof T]: T[K] | null };
type NullableUser = Nullable<User>;
function a(x: NullableUser) { /* ... */ }Avoid unbounded recursion. Always depth-limit with a tuple counter.
Prefer `interface` to type-intersection for object shapes:
// Slower on large merges
type A = { a: string } & { b: number } & { c: boolean };
// Faster and better error messages
interface A { a: string; b: number; c: boolean }"Type instantiation is excessively deep and possibly infinite" — almost always:
- Recursive conditional without a base case or depth limit.
- Mutually recursive types that loop.
- Excessively wide generic with nested
infer.
Barrel files kill TS. A single src/index.ts re-exporting 200 symbols forces TS (and your IDE) to load everything. Export from the file that defines the symbol.
IDE performance
tsserver slow in VS Code:
- Use workspace TS version.
"typescript.tsserver.maxTsServerMemory": 8192"typescript.tsserver.experimental.enableProjectDiagnostics": falseif project-wide errors aren't needed."files.watcherExclude": { "**/.cache/**": true, "**/dist/**": true }
"JavaScript heap out of memory" crashes → find the cyclic or explosive type with --generateTrace + analyze-trace.
Monorepo specifics
tsc --build --force # rebuild everything (cold)
tsc --build # incremental
tsc --build --watch
tsc --build --clean # delete .tsbuildinfo and outputsTurborepo / Nx — cache the type-check task:
// turbo.json
{ "tasks": { "type-check": {
"dependsOn": ["^type-check"],
"inputs": ["src/**/*.ts", "src/**/*.tsx", "tsconfig.json"],
"outputs": []
}}}Type-check becomes free on unchanged packages.
When to pay for perf with maintainability
interface vs type, skipLibCheck, precise includes — free wins. Dropping exactOptionalPropertyTypes or noUncheckedIndexedAccess for speed is not free — ships bugs. Never silently lower strictness.
Runtime Safety — Parse, Don't Validate
The type system protects you from bugs you write. It does nothing against the data you receive. Every boundary with the outside world — HTTP request, JSON file, env var, LLM output, localStorage, IPC, worker message — crosses through a parser, not a validator.
Core distinction
A validator returns a boolean. Code after it still holds untyped data:
// WRONG
function isUser(x: unknown): boolean { /* returns true/false */ }
const raw: unknown = await response.json();
if (isUser(raw)) {
raw.name; // compile error — raw is still `unknown`
}A parser returns a typed value or errors:
// RIGHT
const raw: unknown = await response.json();
const user: User = UserSchema.parse(raw); // throws if invalid, returns User if valid
user.name; // typed, guaranteedPhrase from Alexis King's "Parse, don't validate" — originally about Haskell, equally true in TS.
Schema libraries — which to use
| Library | Bundle | Perf | Inference | When to pick |
|---|---|---|---|---|
| Zod | ~14 KB gzip | Good | Excellent | Default. Ubiquitous ecosystem (tRPC, react-hook-form, drizzle-zod, openapi-zod-client, hono). Best docs. |
| Valibot | ~1 KB + tree-shakable | Faster than Zod | Excellent | Bundle-size-critical (client-side in web apps, edge workers). Tree-shakable imports mean only used validators ship. |
| ArkType | ~7 KB gzip | Fastest (1-100×) | Best | Perf-critical high-throughput parsing, or TS-syntax-as-schema: type({ name: "string", age: "number>0" }). |
Stay with what the project uses. Don't mix unless there's a reason.
Zod — canonical patterns
Basic:
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().nonnegative(),
role: z.enum(["admin", "user", "guest"]),
createdAt: z.coerce.date(), // accepts ISO string → Date
});
type User = z.infer<typeof UserSchema>;
const user: User = UserSchema.parse(await response.json());Safe parse (no throw):
const result = UserSchema.safeParse(await response.json());
if (!result.success) {
logger.warn("Invalid user payload", { errors: result.error.flatten() });
return Result.err("invalid_payload");
}
const user = result.data;Discriminated unions:
const ActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("INCREMENT"), by: z.number() }),
z.object({ type: z.literal("RESET") }),
z.object({ type: z.literal("SET_USER"), user: UserSchema }),
]);
type Action = z.infer<typeof ActionSchema>;Transforms & refinements:
const EmailSchema = z.string()
.email()
.transform(s => s.toLowerCase().trim())
.brand<"Email">();
type Email = z.infer<typeof EmailSchema>;
const NonEmptyString = z.string().min(1, "cannot be empty");Branded types via Zod:
const UserIdSchema = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;
const id = UserIdSchema.parse(rawId); // only way to constructBoundary patterns
HTTP response
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new NetworkError(`HTTP ${response.status}`);
return UserSchema.parse(await response.json());
}Environment variables
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = EnvSchema.parse(process.env);Parse at startup. Malformed env → process dies immediately with a legible error.
LLM / model output
const ResponseSchema = z.object({
intent: z.enum(["greet", "query", "transact", "help"]),
confidence: z.number().min(0).max(1),
entities: z.array(z.object({ name: z.string(), value: z.string() })),
});
const raw: unknown = JSON.parse(completion.content);
const parsed = ResponseSchema.safeParse(raw);
if (!parsed.success) return await retryWithFormatReminder();Models hallucinate shapes. Always parse. Non-negotiable for any LLM integration.
Form submission (react-hook-form + zodResolver)
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type LoginForm = z.infer<typeof LoginSchema>;
const { register, handleSubmit } = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
});Same schema for client + server.
localStorage / sessionStorage
function load<T>(key: string, schema: z.ZodType<T>): T | null {
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
return schema.parse(JSON.parse(raw));
} catch {
localStorage.removeItem(key); // corrupt — drop it
return null;
}
}
const settings = load("settings", SettingsSchema);Never trust your own storage. Users edit it. Browser extensions edit it. Old versions of your app wrote different shapes.
Single-source-of-truth
Schema and type share one source. Never duplicate:
// BAD — will drift
interface User { id: string; email: string; age: number; }
const UserSchema = z.object({ id: z.string(), email: z.string(), age: z.number() });
// GOOD
const UserSchema = z.object({ id: z.string(), email: z.string(), age: z.number() });
type User = z.infer<typeof UserSchema>;Anti-patterns
- Casting past the boundary:
const user = (await response.json()) as User. Parse. - Duplicating type and schema.
- Parsing deep in business logic instead of at the edge. Parse once, at the boundary.
- Catching the parse error and continuing with partial data. Wrong shape → integration bug. Surface loudly.
Strict TypeScript Configuration
Application baseline (copy-paste)
For an application not published to npm:
{
"compilerOptions": {
// --- Language & target -------------------------------------------------
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"], // drop DOM for Node-only
"module": "ESNext",
"moduleResolution": "Bundler", // "NodeNext" for pure Node
"moduleDetection": "force", // every file is a module
// --- Strictness (non-negotiable) ---------------------------------------
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
// --- Module semantics --------------------------------------------------
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
// --- Output ------------------------------------------------------------
"noEmit": true, // tsc type-checks; bundler emits
"skipLibCheck": true, // never hide your own errors with this
// --- DX ----------------------------------------------------------------
"incremental": true,
"tsBuildInfoFile": "./.cache/.tsbuildinfo"
},
"include": ["src/**/*", "tests/**/*", "types/**/*"],
"exclude": ["node_modules", "dist", ".cache"]
}Library additions
For a package published to npm, add:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"isolatedDeclarations": true, // TS 5.5+ — every export needs an explicit return type
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src"
}
}Flag-by-flag rationale
"strict": true
Enables noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, useUnknownInCatchVariables. If existing code doesn't compile, fix the code — don't disable.
"noUncheckedIndexedAccess": true
array[i] and record[key] return T | undefined. Matches reality — nothing stops i from being out of bounds.
const users: User[] = [];
const first = users[0]; // without: User (lie). With: User | undefined (truth).
first.name; // without: runtime crash. With: compile error.Counter-arguments and rebuttals:
- "Annoying, I know it's non-empty" → use
users[0] ?? throwExpression('unexpected empty'), tuple types ([User, ...User[]]), or aNonEmptyArray<T>branded type. - "Pollutes every loop" →
for (const u of users)works identically; flag only affects index access.
"exactOptionalPropertyTypes": true
{ x?: T } means "x may be absent", not "x may be undefined".
interface Config { readonly port?: number }
const a: Config = {}; // OK — absent
const b: Config = { port: 3000 }; // OK — present
const c: Config = { port: undefined }; // With flag: ERROR. Without: accepted.For "may be undefined including explicit undefined", write port: number | undefined (no ?).
"verbatimModuleSyntax": true
import/export syntax preserved verbatim in output. Forces import type explicitly. Catches circular imports that work at type-check time but crash at runtime. Replaces the older importsNotUsedAsValues and preserveValueImports — do not use those.
"isolatedModules": true
Required by every bundler (esbuild, swc, Vite, Turbopack). Forbids:
const enum→ useas constobject instead.- Re-exporting a type without
export type→ useexport type { Foo }. - Ambient const (rare).
"isolatedDeclarations": true — libraries only (TS 5.5+)
Every exported function/class/const needs an explicit type annotation. Makes .d.ts generation purely syntactic (10-100× faster with oxc or isolated-decl).
Bug caught:
// Without flag: inferred return type depends on Prisma → ~50 packages.
// Type-checks take minutes for consumers.
export function getUser(id: string) {
return db.users.findUnique({ where: { id } });
}
// With flag: forced to annotate
export function getUser(id: string): Promise<User | null> { /* ... */ }Leave off for apps; turn on for libraries.
"skipLibCheck": true
Skips type-checking of .d.ts files. Recommended by the TypeScript team. Do not use as a workaround for @types/foo not matching foo — file an issue, pin the compatible version, or augment types.
"noPropertyAccessFromIndexSignature": true
type Dict = { [k: string]: T } → dict.anyKey forbidden, only dict["anyKey"]. Forces the looseness to be visible.
"noImplicitReturns": true
Every branch returns or is typed void. Catches "forgot a return".
"noFallthroughCasesInSwitch": true
Every case ends in break/return/throw/continue. No accidental fall-through.
Monorepo setup
Use project references. Root tsconfig.json:
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/ui" },
{ "path": "./apps/web" }
]
}Each package:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" } // depends on core
],
"include": ["src/**/*"]
}Build with tsc --build (or tsc -b), which respects the reference graph and caches incrementally via .tsbuildinfo.
ESLint rules beyond the compiler
Linters catch patterns that type-check but are still wrong.
{
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports" }],
"@typescript-eslint/consistent-type-exports": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/prefer-readonly": "error",
"@typescript-eslint/ban-ts-comment": ["error", {
"ts-ignore": true,
"ts-expect-error": "allow-with-description",
"minimumDescriptionLength": 10
}]
}
}no-floating-promises alone prevents more production incidents than any other rule.
Biome (faster, narrower rules)
{
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "error",
"noConfusingVoidType": "error",
"noEmptyInterface": "error"
},
"style": {
"useConsistentArrayType": { "level": "error", "options": { "syntax": "generic" } },
"useImportType": "error",
"useExportType": "error"
},
"complexity": {
"noUselessTypeConstraint": "error"
}
}
}
}Testing Types
Types are code. Non-trivial types have branches, edge cases, and regressions. Test them the way you test functions.
Why type tests exist
A generic function like:
function pick<T, const K extends readonly (keyof T)[]>(obj: T, keys: K): Pick<T, K[number]>has invariants the compiler silently relaxes when you refactor:
1. Calling with no keys returns {}. 2. Calling with all keys returns T. 3. Calling with one key returns that one property. 4. Passing a non-keyof T key is a compile error.
Break (4) by accident with a one-char change — K[number] → K[0] — and every runtime test still passes because types don't run. A type test catches it.
vitest expectTypeOf (recommended)
Setup:
// tsconfig.test.json
{
"extends": "./tsconfig.json",
"include": ["tests/**/*.ts", "src/**/*.ts"]
}// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
typecheck: {
enabled: true,
tsconfig: "./tsconfig.test.json",
include: ["**/*.{test,test-d}.ts"],
},
},
});Example:
import { expectTypeOf, test } from "vitest";
import { pick } from "./pick";
test("pick preserves literal keys", () => {
const r = pick({ a: 1, b: "x", c: true }, ["a", "c"] as const);
expectTypeOf(r).toEqualTypeOf<{ a: number; c: boolean }>();
});
test("pick rejects unknown keys", () => {
// @ts-expect-error — 'z' is not a key of the input
pick({ a: 1 }, ["z"] as const);
});
test("pick with empty keys array is empty object", () => {
const r = pick({ a: 1 }, [] as const);
expectTypeOf(r).toEqualTypeOf<{}>();
});
test("pick result narrows properties", () => {
const r = pick({ a: 1 as const, b: 2 }, ["a"] as const);
expectTypeOf(r.a).toEqualTypeOf<1>();
});Run: vitest --typecheck --run.
Full API worth memorizing:
expectTypeOf<A>().toEqualTypeOf<B>(); // exactly equal
expectTypeOf<A>().toMatchTypeOf<B>(); // A assignable to B
expectTypeOf<A>().not.toEqualTypeOf<B>(); // negation
expectTypeOf(value).toEqualTypeOf<T>(); // works on values
expectTypeOf<Fn>().parameters.toEqualTypeOf<[number, string]>();
expectTypeOf<Fn>().returns.toEqualTypeOf<Promise<User>>();tsd — for published libraries
import { expectType, expectError } from "tsd";
import { pick } from "../src/pick";
expectType<{ a: number; c: boolean }>(pick({ a: 1, b: "x", c: true }, ["a", "c"] as const));
expectError(pick({ a: 1 }, ["z"] as const));AssertEqual — inline type assertions
Lightweight checks in regular TS files:
type AssertEqual<T, U> =
(<X>() => X extends T ? 1 : 2) extends (<X>() => X extends U ? 1 : 2) ? true : false;
type Assert<_T extends true> = void;
type _t1 = Assert<AssertEqual<ReturnType<typeof pick>, { a: number; c: boolean }>>;
type _t2 = Assert<AssertEqual<ExtractPathParams<"/users/:id">, { id: string }>>;Assertions live next to the type. A change that breaks the assertion is a compile error in the same file.
More:
type Expect<T extends true> = T;
type Equal<T, U> = AssertEqual<T, U>;
type NotEqual<T, U> = AssertEqual<T, U> extends true ? false : true;
type Extends<T, U> = T extends U ? true : false;
type _a = Expect<Equal<Uppercase<"hi">, "HI">>;
type _b = Expect<Extends<"red" | "green", string>>;
type _c = Expect<NotEqual<string, number>>;Same style as type-challenges.
What to test
Test:
- Complex conditional types, especially recursive.
- Utility types you build (
DeepReadonly,ExactlyOne,ExtractParams). - Generic functions with non-obvious inference (
consttype params,NoInfer). - DSL APIs (caller's experience is the product).
- Type-level state machines.
- Branded-type constructors (can't bypass validation).
Don't test:
- Trivial wrappers.
- Built-in types.
- React component prop types (runtime tests with
@testing-library/reactare better).
Intentional-error tests
// @ts-expect-error — UserId cannot be assigned from plain string
const u: UserId = "raw";@ts-expect-error is bidirectional — if the underlying code changes so this would compile, the comment fails.
CI integration
- run: npx tsc --noEmit # full project type check
- run: npx vitest --typecheck --run # type tests
- run: npx eslint . --max-warnings 0 # lint, including no-explicit-anyAll three must pass. Type tests without tsc --noEmit are worthless — you can have type errors in untested files.