--- name: typescript description: "Use when writing or fixing TypeScript type code (.ts/.tsx/.d.ts), modeling data with generics/unions/branded types, diagnosing narrowing failures, or configuring tsconfig and the build toolchain. NOT runtime code, fs/streams/http (that is `nodejs`); NOT component/route typing (that is `react`/`nextjs`); NOT ORM type-gen (that is `drizzle-orm`)." tags: [typescript, types, generics, tsconfig, type-safety] recommends: [nodejs, secure-coding, drizzle-orm] origin: risco --- # Idiomatic TypeScript types Express data and contracts precisely in the TypeScript type system, and configure the compiler so the types actually hold. Targets **TypeScript 5.9** (current stable, Q1 2026): redesigned `tsc --init` defaults (`module: nodenext`, `target: esnext`, `moduleDetection: force`, strict on), `import defer` for deferred module evaluation, expandable editor hovers. The **TypeScript 7 native compiler** (`tsgo`, the Go port) is in preview as `@typescript/native-preview` and type-checks roughly 10x faster (VS Code's 1.5M LOC: 89s -> 8.74s) — adopt it for fast local checks; stable 7.0 lands early 2026. ## Boundary: language vs runtime vs framework Three layers, three owners. **Language + compiler config lives here** — generics, narrowing, `.d.ts`, `tsconfig`, and the build toolchain (`tsc` vs `tsgo`, `tsx`, resolution mode). **Runtime behavior** (how a stream backpressures, how the event loop schedules) is `nodejs`. **Framework idioms** (component props, route maps, server-component typing) are `react`/`nextjs`/`nestjs` — those *borrow* this skill's type mechanics but own their wiring. A `tsconfig` question is here; a "stream backpressure" question is `nodejs`; a "type my `useReducer` state" question is `react`. Also delegate: ORM schema-to-type generation -> `drizzle-orm` / `prisma-orm`; REST/contract shape design -> `api-design`; test runner and E2E setup -> `testing-web` / `e2e-testing`; injection/authz review -> `secure-coding`; Python typing -> `python`; Go -> `go`. ## Decision rules Apply these on every TypeScript edit: 1. **Prefer inference; annotate boundaries.** Let TS infer locals and returns; annotate function parameters, public APIs, and `.d.ts`. Why: redundant annotations drift from reality and suppress the better inferred type. 2. **`import type` for type-only imports**, with `verbatimModuleSyntax: true`. Why: it prevents emitting type-only imports as runtime `require`s and lets esbuild/swc transpile each file alone. 3. **Narrow, don't cast.** Reach for type guards, `in`, `typeof`, discriminant checks before `as`; when `as` is unavoidable, only `as ` or `as unknown as T` at a true I/O boundary. Why: `as` asserts a claim the compiler cannot verify — it is a silenced error, and `as` to a *wider* or unrelated type is almost always a real bug hidden. ## The type-system toolbox ### Generics + constraints, and `const` type parameters Constrain type parameters so callers get errors at the call site, and use a `const` type parameter (TS 5.0+) to preserve literal inference without `as const` everywhere: ```ts // Bad: unconstrained T, and the literal "a"|"b" widens to string. function pick(obj: T, key: string) { return (obj as any)[key]; } // Good: K is constrained to obj's keys; const T keeps the literal union. function pick(obj: T, key: K): T[K] { return obj[key]; } const r = pick({ a: 1, b: "x" } as const, "b"); // r: "x", not string ``` ### `unknown` over `any` at every boundary `any` disables checking and silently poisons every downstream type it touches; `unknown` forces a narrow before use. ```ts // Bad: any flows everywhere, no error ever fires downstream. function parse(json: string): any { return JSON.parse(json); } // Good: unknown forces a narrow before the value is trusted. function parse(json: string): unknown { return JSON.parse(json); } const data = parse(input); if (typeof data === "object" && data && "id" in data) { /* now narrowed */ } ``` ### `satisfies` for config validation without widening ```ts type Route = { path: string; method: "GET" | "POST" }; // Bad: annotation widens — routes.home.method is "GET" | "POST", and a typo in // a key would only surface far away. const a: Record = { home: { path: "/", method: "GET" } }; // Good: satisfies validates each entry AND keeps literal types. const routes = { home: { path: "/", method: "GET" }, } satisfies Record; routes.home.method; // "GET" (narrow), and "GE" would error right here ``` ### Discriminated unions + exhaustive `never` Model state as a discriminated union, not an optional-field bag: `{ data?: T; error?: E; loading?: boolean }` permits `loading && error`; a literal discriminant makes illegal states unrepresentable. That discriminant must be a **literal** type (not `string`), and you must check it **before** destructuring — destructuring first throws away the narrowing. ```ts type State = | { status: "loading" } | { status: "ok"; data: string } | { status: "error"; message: string }; // Bad: destructured before the check — `data`/`message` are typed as the union. function render({ status, data, message }: State) { /* no narrowing */ } // Good: switch on the literal discriminant, exhaustive never guard at the end. function render(s: State): string { switch (s.status) { case "loading": return "..."; case "ok": return s.data; case "error": return s.message; default: { const _exhaustive: never = s; return _exhaustive; } } } ``` Add a `"deleted"` member to `State` and the `default` branch fails to compile — that is the point. ### Branded (nominal) types TS is structural, so a `UserId` and a raw `string` are interchangeable unless you brand them: ```ts type UserId = string & { readonly __brand: "UserId" }; const asUserId = (s: string): UserId => s as UserId; // brand only at the validated boundary function load(id: UserId) { /* a bare string is now a compile error */ } ``` ### Utility types cheat row `Partial` `Required` `Readonly` `Pick` `Omit` `Record` `Exclude` `Extract` `NonNullable` `ReturnType` `Parameters` `Awaited` `NoInfer` (TS 5.4+, blocks a type param from inferring at one site). ### Mapped / conditional / template-literal one-liners ```ts type Mutable = { -readonly [K in keyof T]: T[K] }; // strip readonly type Nullable = { [K in keyof T]: T[K] | null }; // mapped transform type Unwrap = T extends Promise ? U : T; // conditional + infer type EventName = `on${Capitalize}`; // template literal ``` Deep recipes (recursive types, declaration merging, variance/`in`-`out`, assertion functions, overloads) live in [references/type-system.md](references/type-system.md). ## tsconfig: strict base Copy-paste base. Every package `extends` this and overrides only paths/references: ```jsonc { "compilerOptions": { "strict": true, // the whole strict family, non-negotiable "noUncheckedIndexedAccess": true, // arr[i] / rec[k] are T | undefined "verbatimModuleSyntax": true, // explicit import type; safe single-file transpile "isolatedModules": true, // each file transpilable alone (esbuild/swc/babel) "skipLibCheck": true, // don't type-check node_modules .d.ts (speed) "resolveJsonModule": true, // import data.json with types "target": "esnext", "module": "nodenext", // or "esnext" + "moduleResolution": "bundler" "moduleResolution": "nodenext", "moduleDetection": "force", "noEmit": true // a bundler/tsc -b emits; the base just checks } } ``` ### moduleResolution: bundler vs nodenext | Aspect | `bundler` | `nodenext` | | --- | --- | --- | | Use when | Vite / Next / Remix / esbuild build the code | Code runs directly in Node (no bundler) | | Relative import extensions | omit (`./util`) | required (`./util.js`) | | `package.json` `exports`/`imports` | resolved | resolved | | `module` setting | `esnext`/`preserve` | `nodenext` | | Emits runnable JS itself | no (the bundler does) | yes | Pick `bundler` when a bundler owns resolution; pick `nodenext` for libraries and scripts that Node executes directly. Field-by-field reference and the full matrix are in [references/build-and-monorepo.md](references/build-and-monorepo.md). ## Monorepo & build Use **project references** so packages type-check independently and in dependency order. A shared `tsconfig.base.json` holds the strict flags; each package extends it, sets `composite: true`, `outDir`/`rootDir`, and lists upstream packages under `references`. Build the graph with `tsc -b` (or `tsgo -b`). For declaration emit and publishing a library, set `declaration: true`, `declarationMap: true`, and an `exports` map in `package.json`. For fast local checks prefer `tsgo` (TS7 preview: `npx @typescript/native-preview`); run scripts with `tsx` instead of `ts-node`. Depth, build-order graph, path aliases, and the `tsgo` adoption steps are in [references/build-and-monorepo.md](references/build-and-monorepo.md). ## Anti-patterns | Anti-pattern | Why it bites | Do instead | | --- | --- | --- | | `any` as an escape hatch | disables all checking and spreads silently downstream | `unknown` + narrow, or a precise type / generic | | `as` to silence "not assignable" | asserts a claim TS can't verify — hides the real bug | fix the type, or narrow with a guard/`in`/`typeof` | | `enum` for a small fixed set | emits runtime code, awkward with `isolatedModules`, no literal subtyping | union of string literals (`"a" \| "b"`) + `as const` | | Destructure before the discriminant check | narrowing is lost; fields become the full union | check `obj.kind` first, then read fields | | Skipping `noUncheckedIndexedAccess` | `arr[i]`/`rec[k]` typed as `T` but are `undefined` at runtime | enable it; handle the `\| undefined` | | `Function` / bare `object` / `{}` types | accept almost anything, no call-signature safety | a precise signature, or `Record` | | No exhaustive `never` guard on unions | a new union member compiles with a missing branch | `default: const _: never = x` in every switch | | One giant `tsconfig` for a monorepo | can't express per-package targets; breaks references | `tsconfig.base.json` + per-package `extends` | | `// @ts-ignore` | swallows the error even after the line is fixed, masking new ones | `// @ts-expect-error` (errors if the line later type-checks) | | Default-importing in a CJS-emit file under `verbatimModuleSyntax` | blocked / wrong emit | use `export =`/named, or set the right `module` | ## Project grounding When working inside a real workspace, record this project's tsconfig and strictness choices (resolution mode, which strict flags are on, the build tool) in the project wiki via [../harness/SKILL.md](../harness/SKILL.md) — so the next session does not re-derive them.