Modern TypeScript
Overview
Guidance for idiomatic TypeScript on current stable 5.x (as of 2026). The goal: let the
compiler prove things so you don't have to test them. Modern TS means strict on from day one,
letting inference do the work, reserving any for genuine escape hatches, and using unions +
narrowing instead of runtime type tags. TypeScript's type system is structural (shape-based,
not name-based) — internalizing that explains most surprises.
This skill covers the type system and compiler config. It is framework-agnostic: for React/Next specifics see the frontend skills noted at the bottom.
Setup
Install and initialize:
npm i -D typescript # or: pnpm add -D typescript
npx tsc --init # scaffolds tsconfig.json
npx tsc --noEmit # type-check only (bundler does the emit)
Strict baseline tsconfig.json. Every option below is a real, documented 5.x flag — do not
substitute made-up names.
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2023"], // add "dom","dom.iterable" for browser code
"module": "nodenext", // Node: nodenext | bundler apps: "preserve"
"moduleResolution": "nodenext", // bundlers (Vite/esbuild/webpack): "bundler"
"moduleDetection": "force", // treat every file as a module (no accidental globals)
// Correctness — the important half
"strict": true, // umbrella: noImplicitAny, strictNullChecks,
// strictFunctionTypes, useUnknownInCatchVariables, ...
"noUncheckedIndexedAccess": true, // arr[i] and record[k] become T | undefined
"exactOptionalPropertyTypes": true, // `x?: T` cannot be explicitly set to undefined
"noImplicitOverride": true, // require `override` keyword
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// Module hygiene
"verbatimModuleSyntax": true, // forces `import type`; predictable emit, no elision
"isolatedModules": true, // safe for per-file transpilers (esbuild/swc/babel)
"esModuleInterop": true,
"skipLibCheck": true, // skip checking .d.ts of deps (faster, standard)
// Emit (drop if a bundler emits and you only type-check)
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"noEmit": true // remove when tsc is the emitter
}
}
Rules of thumb: Node/library → module+moduleResolution both "nodenext".
App built by a bundler → "module": "preserve", "moduleResolution": "bundler", "noEmit": true.
noUncheckedIndexedAccess is the single highest-value flag not included in strict — turn it on.
Core patterns
unknown, never any, at boundaries. any disables checking and spreads silently; unknown
forces a narrow before use.
const data: unknown = JSON.parse(raw); // JSON.parse returns any — annotate to unknown
if (typeof data === "object" && data !== null && "id" in data) {
// data narrowed; validate the rest with a schema lib (zod/valibot) for real safety
}
Narrowing via control-flow analysis — typeof, instanceof, in, truthiness, equality:
function len(x: string | string[]): number {
return typeof x === "string" ? x.length : x.length; // each branch narrowed
}
Discriminated (tagged) unions — the workhorse for modeling states. A shared literal
kind/type field lets TS narrow the whole object:
type Result<T> =
| { kind: "ok"; value: T }
| { kind: "err"; error: Error };
function unwrap<T>(r: Result<T>): T {
switch (r.kind) {
case "ok": return r.value; // r is the ok variant here
case "err": throw r.error;
}
}
Exhaustiveness checks with never — the compiler flags a missing case when the union grows:
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
// in the switch: default: return assertNever(r); // TS2345 if a case is unhandled
Generics with constraints — accept the least you need; let call sites infer the rest:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // return type is precise, not `unknown`
}
const name = pluck({ id: 1, name: "a" }, "name"); // typed as string
as const freezes literals to their narrowest readonly type; satisfies validates a value
against a type without widening it (you keep the precise inferred type):
const ROUTES = { home: "/", user: "/u/:id" } as const;
// typeof ROUTES.home === "/" (literal, readonly)
const config = {
port: 3000,
env: "prod",
} satisfies Record<string, string | number>; // checked, but config.port stays `number`
Utility types — reach for the built-ins before writing mapped types by hand:
Partial<T>, Required<T>, Readonly<T>, Pick<T,K>, Omit<T,K>, Record<K,V>,
Exclude<U,X>, Extract<U,X>, NonNullable<T>, ReturnType<F>, Parameters<F>,
Awaited<T>, and NoInfer<T> (5.4, blocks inference from a given position).
Template-literal types for string-shaped APIs:
type Event = `on${Capitalize<"click" | "hover">}`; // "onClick" | "onHover"
type Hex = `#${string}`;
Branded (nominal) types — defeat structural typing when two strings must not mix:
type UserId = string & { readonly __brand: "UserId" };
const toUserId = (s: string) => s as UserId;
function load(id: UserId) { /* ... */ }
// load("raw") -> TS2345: plain string not assignable to UserId
Custom type guards & assertion functions encapsulate narrowing:
function isString(x: unknown): x is string { return typeof x === "string"; }
function assertDefined<T>(x: T): asserts x is NonNullable<T> {
if (x == null) throw new Error("expected defined");
}
Gotchas / best practices
any-creep. Oneany(a cast, an untyped dep,JSON.parse) silently infects everything it touches. Preferunknownat boundaries;strict(vianoImplicitAny) catches implicit ones, but explicitanyand third-partyanyslip through — grep for them in review.- Structural typing surprises. Types match by shape, so an object with extra properties is assignable to a narrower type — except the excess-property check fires only on fresh object literals. Assigning through a variable bypasses it. Two unrelated types with the same shape are interchangeable; use branded types when identity matters.
Object.keys/for..inreturnstring[], not(keyof T)[]— deliberately, since objects can hold extra keys at runtime. Cast or re-validate rather than assuming.- ESM/CJS interop. With
verbatimModuleSyntax, type-only imports must useimport type(else TS2823-style emit errors). UndernodenextESM, relative imports need explicit extensions (./x.js, even fromx.ts), and the package must declare"type": "module".esModuleInteropmakesimport x from "cjs-pkg"work. - enum vs union. Prefer a union of string literals or an
as constobject overenum. Numericenums are unsafe (anynumberis assignable to them);const enumbreaks underisolatedModules/verbatimModuleSyntax. Literal unions are erasable, tree-shakeable, and narrow cleanly. - Don't over-annotate. Let inference type locals and return values; annotate function
parameters, public API boundaries, and empty containers (
const xs: string[] = []). - Reading common
tscerrors:Code Meaning Usual fix TS2322 Type X not assignable to Y shape/variance mismatch; check the diff TS prints TS2345 Argument X not assignable to parameter Y wrong arg type (also fired by assertNever)TS2339 Property does not exist on type narrow first, or type is wrong/too wide TS18048 / TS2532 X is possibly 'undefined' guard it; effect of strictNullChecks/noUncheckedIndexedAccessTS18047 X is possibly 'null' null guard TS7006 Parameter implicitly has 'any' add a parameter annotation TS18046 X is of type 'unknown' narrow before use TS2769 No overload matches this call args match no signature; read each listed overload TS2367 Comparison appears unintentional comparing non-overlapping types (often a bug)
Use this vs related skills
modern-python is the Python analogue (tooling + idioms); for React/Next specifics use the
frontend framework and design skills (e.g. vercel-react-best-practices) — this skill is the
language/type-system and tsconfig layer beneath them.