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:
- 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. import typefor type-only imports, withverbatimModuleSyntax: true. Why: it prevents emitting type-only imports as runtimerequires and lets esbuild/swc transpile each file alone.- Narrow, don't cast. Reach for type guards,
in,typeof, discriminant checks beforeas; whenasis unavoidable, onlyas <narrower>oras unknown as Tat a true I/O boundary. Why:asasserts a claim the compiler cannot verify — it is a silenced error, andasto 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:
// Bad: unconstrained T, and the literal "a"|"b" widens to string.
function pick<T>(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<const T, K extends keyof T>(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.
// 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
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<string, Route> = { home: { path: "/", method: "GET" } };
// Good: satisfies validates each entry AND keeps literal types.
const routes = {
home: { path: "/", method: "GET" },
} satisfies Record<string, Route>;
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.
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:
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<T> Required<T> Readonly<T> Pick<T,K> Omit<T,K> Record<K,V>
Exclude<U,M> Extract<U,M> NonNullable<T> ReturnType<F> Parameters<F>
Awaited<T> NoInfer<T> (TS 5.4+, blocks a type param from inferring at one site).
Mapped / conditional / template-literal one-liners
type Mutable<T> = { -readonly [K in keyof T]: T[K] }; // strip readonly
type Nullable<T> = { [K in keyof T]: T[K] | null }; // mapped transform
type Unwrap<T> = T extends Promise<infer U> ? U : T; // conditional + infer
type EventName<T extends string> = `on${Capitalize<T>}`; // template literal
Deep recipes (recursive types, declaration merging, variance/in-out, assertion functions,
overloads) live in references/type-system.md.
tsconfig: strict base
Copy-paste base. Every package extends this and overrides only paths/references:
{
"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.
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.
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<string, unknown> |
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 — so the next session does not re-derive them.