| name | modern-typescript |
| description | Modern TypeScript 5.x idioms — strict tsconfig (strict, noUncheckedIndexedAccess), the type system (unions/intersections, generics + constraints, narrowing, discriminated unions, unknown vs any, utility types, satisfies, as const, template-literal types), ESM & moduleResolution, and type-safe patterns (branded types, exhaustiveness). Use when configuring a tsconfig, writing or reviewing type-safe TypeScript, designing generics or a discriminated union, or decoding a tsc error. |
| license | MIT |
| metadata | {"version":"1.0","skill-author":"vault-audit"} |
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
npx tsc --init
npx tsc --noEmit
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"],
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
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);
if (typeof data === "object" && data !== null && "id" in data) {
}
Narrowing via control-flow analysis — typeof, instanceof, in, truthiness, equality:
function len(x: string | string[]): number {
return typeof x === "string" ? x.length : x.length;
}
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;
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)}`);
}
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];
}
const name = pluck({ id: 1, name: "a" }, "name");
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;
const config = {
port: 3000,
env: "prod",
} satisfies Record<string, string | 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">}`;
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) { }
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. One any (a cast, an untyped dep, JSON.parse) silently infects everything it
touches. Prefer unknown at boundaries; strict (via noImplicitAny) catches implicit ones,
but explicit any and third-party any slip 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..in return string[], 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 use import type
(else TS2823-style emit errors). Under nodenext ESM, relative imports need explicit
extensions (./x.js, even from x.ts), and the package must declare "type": "module".
esModuleInterop makes import x from "cjs-pkg" work.
- enum vs union. Prefer a union of string literals or an
as const object over enum.
Numeric enums are unsafe (any number is assignable to them); const enum breaks under
isolatedModules/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
tsc errors:
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.