TypeScript Language Expertise
Overview
Type-safe, production-quality TypeScript: the type system's sharp edges, strict-mode config that actually moves the needle, the type-vs-runtime boundary, and framework patterns (zod/tRPC/Prisma/React/Express). Assumes fluency with JS and basic TS — this is reference for the traps and idioms that bite experienced engineers.
Toolchain assumed throughout: Bun (install/run/test), Vite (app dev server and bundler — Rolldown bundling, Oxc transforms and minifier, Lightning CSS), tsdown (library bundler on Rolldown), oxlint (lint), oxfmt (format), tsc --noEmit (type-check). ESLint, Prettier, esbuild, and Babel are the legacy equivalents — do not add them to a project that has this stack. See Toolchain below.
Type System Essentials
Generics
// Constraints + default type param; `this` return for fluent chaining
interface HasId { id: string }
class Repo<T extends HasId = HasId> {
private items = new Map<string, T>();
save(item: T): this { this.items.set(item.id, item); return this; }
find(id: string): T | undefined { return this.items.get(id); }
}
// Return `as const` to preserve a tuple/literal shape instead of widening
function pair<A, B>(a: A, b: B) { return [a, b] as const; } // readonly [A, B]
Utility types
| Utility | Result |
| ----------------------------- | --------------------------------------------- |
| Partial<T> / Required<T> | all props optional / required |
| Readonly<T> | all props readonly (shallow) |
| Pick<T,K> / Omit<T,K> | keep / drop keys K |
| Record<K,V> | object with keys K, values V |
| Extract<U,V> / Exclude<U,V> | keep / drop union members of U assignable to V |
| NonNullable<T> | strip null / undefined |
| ReturnType<F> / Parameters<F> | function return type / param tuple |
| Awaited<T> | recursively unwrap Promise (prefer over a hand-rolled Unwrap) |
// Derive types from values so they can't drift:
function createUser(name: string, email: string): User { /* … */ }
type NewUser = ReturnType<typeof createUser>; // User
type NewUserArgs = Parameters<typeof createUser>; // [string, string]
Conditional types & infer
type Elem<T> = T extends (infer U)[] ? U : never;
type Ret<T> = T extends (...a: any[]) => infer R ? R : never;
// Conditionals DISTRIBUTE over naked union type params:
type ToArray<T> = T extends any ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
// Wrap both sides in a 1-tuple to DISABLE distribution:
type ToArray1<T> = [T] extends [any] ? T[] : never;
type B = ToArray1<string | number>; // (string | number)[]
Mapped types
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]+?: T[K] };
// Key remapping via `as` (TS 4.1+): rename or filter keys
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };
// PickByValue<{a:string;b:number}, string> → { a: string }
Discriminated unions & exhaustiveness
Model each valid state as a variant with a shared literal discriminant; switch on it narrows each arm, and an assertNever default turns "added a variant, forgot a case" into a compile error.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function assertNever(x: never): never { throw new Error(`Unreachable: ${JSON.stringify(x)}`); }
function render(s: State): string {
switch (s.status) {
case "idle": return "Click to load";
case "loading": return "Loading…";
case "success": return `Loaded ${s.data.length}`; // s narrowed → data exists
case "error": return s.error.message; // s narrowed → error exists
default: return assertNever(s); // ← new unhandled variant = type error
}
}
⚠ Exhaustiveness relies on a finite discriminant. A numeric enum discriminant accepts any number, so assertNever won't catch a missing case — use string-literal unions or as const objects (see Anti-Patterns).
Type guards, assertion functions, unknown vs any
anydisables checking transitively — it silently poisons every expression it flows into.unknownis the safe top type: assignable from anything, assignable to nothing until you narrow.- Type external inputs (
JSON.parse,fetch().json(),catchvars,process.envshapes) asunknownand narrow withtypeof/instanceof/in/ a validator before use.
function isCat(a: Cat | Dog): a is Cat { return "meow" in a; }
// Assertion function: narrows for the rest of the scope, throws otherwise
function assertNonNull<T>(v: T | null | undefined, msg?: string): asserts v is T {
if (v == null) throw new Error(msg ?? "value is null/undefined");
}
⚠ An explicit x is T predicate is trusted, not verified by the compiler — a wrong body is as unsafe as as. Prefer letting TS infer the predicate (TS 5.5+, see Gotchas); reserve explicit is for what inference can't express, and unit-test those.
tsconfig & Strict Mode
module/moduleResolution must match where the output runs — there is no universal template. Pick ONE of the two below; never copy a nodenext config into a bundler app or vice versa.
Node app or published library — module: nodenext
Requires explicit .js extensions on relative imports plus "type": "module" in package.json (or .mts/.cts). nodenext implies a matching lib/target, so an explicit "lib" is redundant here.
{
"compilerOptions": {
"module": "nodenext",
"verbatimModuleSyntax": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Bundler app (Vite / Rolldown)
Extensionless relative imports work; the bundler emits, so tsc only type-checks (noEmit). ⚠ Never use moduleResolution: bundler for a published library — it's "infectious": emitted .d.ts files carry extensionless relative imports that break Node.js ESM consumers. Vite transpiles TS with Oxc, which strips types without checking them — tsc --noEmit stays the type gate (oxlint --type-aware covers the lint half, not the compiler's).
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}
Flags beyond strict
strict: true does NOT include these — opt in explicitly (TS 5.9 tsc --init now enables them for new projects):
noUncheckedIndexedAccess— adds| undefinedto array subscripts and index-signature access (arr[i]: T | undefined). ⚠ NOT applied to named properties, NOT tofor...ofloop variables (by design, microsoft/TypeScript#42622), NOT toObject.values()— so it's no safety net when iterating.exactOptionalPropertyTypes—obj.x = undefinedbecomes an error forx?: "a" | "b"; only deleting the key makes the property absent. Matters for"x" in objchecks and serialization round-trips.useUnknownInCatchVariables(on viastrictsince 4.4) —catchvars areunknown; guard withinstanceof Error.verbatimModuleSyntax— modern module-safety baseline; supersedes the now-no-opimportsNotUsedAsValues/preserveValueImports(see Modules).isolatedModules— forbids constructs single-file transpilers can't handle (re-exporting a type withouttype,const enuminlining); required for single-file transpilers — Oxc (Vite, tsdown), Bun's transpiler, esbuild, swc, Babel.
Checklist: tsconfig
- [ ] Exactly one of
nodenext/bundlerchosen to match the runtime — not mixed - [ ]
strict: truePLUSnoUncheckedIndexedAccess+exactOptionalPropertyTypes(they aren't instrict) - [ ]
verbatimModuleSyntax: true; type-only imports carry thetypemodifier - [ ] Library build:
declaration: true, NOTmoduleResolution: bundler, noconst enumin public.d.ts - [ ]
isolatedModules: trueif any non-tsctranspiler is in the build
Toolchain: Bun + Vite + Oxc
One stack, no overlap: Bun installs, runs scripts, and runs bun test; Vite builds apps (Rolldown bundler, Oxc transform + minifier, Lightning CSS); tsdown builds libraries (Rolldown); oxlint lints; oxfmt formats; tsc --noEmit type-checks. Oxc replaces ESLint, Prettier, esbuild, and Babel — never add those to a project that has this stack, and never run two formatters over the same files; a second linter is acceptable only as a temporary migration bridge (see oxlint below).
bun add -D typescript oxlint oxfmt
bunx oxlint --init # writes .oxlintrc.json
bunx oxfmt --init # writes .oxfmtrc.json (--migrate=prettier converts an existing Prettier config)
bun add -D oxlint-tsgolint # needed by "typeAware": true in the config below; drop both to skip type-aware rules
package.json scripts:
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"check": "bun run typecheck && bun run lint && bun run format:check"
}
}
oxlint
- Config is
.oxlintrc.json. Rules are grouped in categories; onlycorrectnessis on by default — opt intosuspicious,perf,pedanticper project rather thanall. - Setting
pluginsREPLACES the default set (eslint,typescript,unicorn,oxc) — list the defaults you keep alongside the ones you add (import,promise,vitest,react,jsx-a11y, ...). oxlint --fixapplies only safe fixes;--fix-suggestionsand--fix-dangerouslycan change behavior — review the diff.- Type-aware rules (
typescript/no-floating-promises,typescript/no-unsafe-assignment, ...) needoxlint-tsgolintplus"options": { "typeAware": true }(oroxlint --type-aware). They run on the native TypeScript compiler (tsgo), so the tsconfig must be tsgo-clean — nobaseUrl— andincludeshould stay tight or the pass gets slow. - Migrating from ESLint:
bunx -p @oxlint/migrate oxlint-migrateconverts a flat config to.oxlintrc.json. If a rule oxlint lacks must stay, keep ESLint for that rule only, addeslint-plugin-oxlintto turn off everything oxlint already covers, and runoxlint && eslintso the fast pass fails first.
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "import", "promise"],
"categories": { "correctness": "error", "suspicious": "warn" },
"rules": {
"typescript/no-explicit-any": "error",
"typescript/explicit-function-return-type": "error",
"typescript/method-signature-style": ["error", "property"],
"import/no-cycle": "error"
},
"options": { "typeAware": true },
"ignorePatterns": ["dist/**", "coverage/**"]
}
oxfmt
- Prettier-compatible output, Prettier-style CLI:
oxfmtwrites in place,oxfmt --checkfor CI. Config is.oxfmtrc.json; it honors.gitignoreand.prettierignoreby default and formats TS/JS/JSX/TSX, JSON, CSS, Markdown, YAML and more. - Default
printWidthis 100 (Prettier's is 80). Matching an existing Prettier codebase: generate the config withoxfmt --migrate=prettieror setprintWidthexplicitly, then land one formatting-only commit. sortPackageJsonis on by default;sortImportsis opt-in. Drop Prettier and its ESLint bridges (eslint-config-prettier,eslint-plugin-prettier) — oxlint and oxfmt do not overlap under the default categories (styleis off), so nothing needs silencing.
tsdown for libraries
Bundle published packages with tsdown (Rolldown under the hood): tsdown.config.ts with defineConfig({ entry: ["src/index.ts"], format: ["esm"], dts: true }), nodenext resolution in tsconfig (see above), and isolatedDeclarations: true so .d.ts emit is per-file (see Performance). It replaces tsup/rollup/esbuild-based library builds.
Bun as runtime
bun run src/x.ts and bun test transpile TypeScript themselves — no build step, no ts-node/tsx loader. Bun strips types without checking them, exactly like Vite, so tsc --noEmit is still the gate. Use bun test for Node/Bun-target code and Vitest (which runs on Vite's Oxc transform) for browser-target code.
Loom Test Runner Adapter
Adapter. One of vitest, jest, mocha, bun-test, node-test, picked per package in this order: vitest, then jest, then mocha, by presence in dependencies/devDependencies; else bun-test when bun.lock/bun.lockb exists or scripts.test starts with bun test; else node-test when scripts.test contains node --test; else none (loom project detect prints runner=unsupported). Plain JavaScript packages (package.json without tsconfig.json) get the same order and this skill. A package that lists vitest gets vitest even where some files use bun:test (the split above); a contract on a bun test file sets runner: bun-test, which overrides detection.
Single-test command, run with the package directory as cwd:
bunx vitest run {file} -t '{test}' # vitest; npx without a bun lockfile
bunx jest {file} -t '{test}' # jest; npx without a bun lockfile
bunx mocha {file} --grep '{test}' # mocha; npx without a bun lockfile
bun test {file} -t '{test}' # bun-test
node --test --test-name-pattern='^{test}$' {file} # node-test
The test field is the test's full name: the enclosing describe titles and the test title, joined by single spaces (mocha calls it the full title). For this test it is spool rejects a symlinked spool directory:
import * as spool from "./spool";
describe("spool", () => {
it("rejects a symlinked spool directory", async () => {
await expect(spool.openSpool(linkedDir)).rejects.toThrow(/symlink/);
});
});
Every one of these filters is a regular expression, and only the node-test command anchors it. Title contract tests in plain words: no ' (the command single-quotes the value), no regex metacharacters (( ) [ ] { } . ? + * ^ $ | \), and no full name that is a substring of another full name in the same file, or the filter selects both.
No match. vitest, jest, mocha and node-test exit 0 when the filter matches nothing (vitest and jest report every test skipped, mocha prints 0 passing); bun-test exits 1 (regex "..." matched 0 tests). Loom classifies the run from the runner's summary: zero executed tests is NotSelected whatever the exit code, so a contract whose test does not match fails the freeze ("the runner did not select the test").
Writing contract tests. Test files match *.test.* and *.spec.* (.js, .jsx, .ts, .tsx, .mjs, .cjs) in any directory, plus anything under __tests__/. One contract, one it/test, titled after what it rejects.
contracts:
- id: rejects-symlinked-spool
file: src/spool/spool.test.ts
test: spool rejects a symlinked spool directory
scenario: points the spool path at a symlink into a temp dir, then calls openSpool()
rejects: an openSpool() that follows the symlink and writes into the link target
Type errors are not red. Vitest, Bun and Node strip types without checking them, as does Jest under Babel or SWC, so a contract test has to fail when it runs: on an assertion, or on a TypeError from calling what does not exist yet. Keep the file loadable so that failure happens inside the test: Bun and Node link ES modules strictly, and a named import of an export that does not exist yet fails the whole file before any test runs. Reach new exports through a namespace import, as in the example above. tsc --noEmit stays the type gate.
Modules & Declaration Merging
Type-only imports
Under verbatimModuleSyntax, an import/export WITHOUT a type modifier is emitted verbatim; anything WITH type is erased. A purely-type import missing type ships as a runtime import/require — defeating tree-shaking and dragging CJS/ESM side effects in.
import type { User, Order } from "./models"; // fully erased
import { type UserDTO, createUser } from "./user"; // UserDTO erased, createUser kept
export { Order, type OrderDTO } from "./order"; // OrderDTO erased
Declaration merging
Interfaces (unlike type aliases) merge across declarations. Deliberate uses: augmenting third-party/global types; pairing an interface with a namespace or class of the same name.
// Augment a library type (must be inside its module scope or a `declare module`):
declare global {
namespace Express { interface Request { user?: User; requestId: string } }
}
declare module "untyped-pkg" { export function doThing(v: string): void }
export {}; // makes an ambient file a module
⚠ The flip side is a footgun: two same-named interfaces in one scope merge silently — often an accidental collision. Use a type alias for shapes you don't want merged (a duplicate type is a hard error, which is what you want).
Runtime Validation Boundary
Types are erased at runtime — they cannot validate data crossing a trust boundary (HTTP body, JSON, env, DB rows). Parse with a schema validator at the edge; inside the boundary, trust the types. Never bridge the boundary with as.
Zod
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]).default("user"),
createdAt: z.coerce.date(), // string → Date at parse time
});
type User = z.infer<typeof UserSchema>; // OUTPUT type (post default/transform/coerce)
type UserIn = z.input<typeof UserSchema>; // INPUT type (what .parse ACCEPTS)
UserSchema.parse(input); // throws ZodError
UserSchema.safeParse(input); // { success: true; data } | { success: false; error }
// Compose instead of redefining:
UserSchema.partial(); // all optional
UserSchema.pick({ email: true });
UserSchema.omit({ createdAt: true });
UserSchema.extend({ age: z.number().int().positive() });
// Cross-field validation:
z.object({ pw: z.string().min(8), confirm: z.string() })
.refine((d) => d.pw === d.confirm, { message: "mismatch", path: ["confirm"] });
⚠ z.infer is the output type. Whenever a schema uses .default(), .transform(), or .coerce, input ≠ output — annotate parse inputs with z.input and results with z.infer. Passing an z.infer value where z.input is expected is a common, silent shape bug.
tRPC
const t = initTRPC.context<Context>().create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string().uuid() })) // runtime validation AND static input type
.query(({ input, ctx }) => ctx.db.user.find(input.id)),
});
export type AppRouter = typeof appRouter; // the ONLY thing the client imports
// Client — I/O types flow across the wire from the exported TYPE, no codegen:
import type { AppRouter } from "./server";
const client = createTRPCClient<AppRouter>({ url });
await client.getUser.query({ id: "…" }); // fully typed, autocompleted
The client imports the type only (import type), so no server code ships to the browser; the .input() schema doubles as runtime guard and static contract.
Prisma
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { where: { published: true }, take: 10 } },
}); // return type NARROWS to User & { posts: Post[] } from the include
// Name a query shape instead of hand-writing the joined type:
type UserWithPosts = Prisma.UserGetPayload<{ include: { posts: true } }>;
await prisma.$transaction(async (tx) => { /* all-or-nothing */ });
select/include reshape the result type, not just the query — select prunes fields from the returned type. Use Prisma.<Model>GetPayload<…> to derive a shape rather than duplicating it by hand.
Patterns
Branded (nominal) types
TS is structural, so UserId and OrderId (both string) are interchangeable unless you brand them. Validate in the constructor; the brand makes mix-ups a compile error.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
function toEmail(s: string): Email {
if (!s.includes("@")) throw new Error("invalid email");
return s.toLowerCase() as Email; // the ONE sanctioned cast, gated by validation
}
// getUser(orderId) // ← type error: OrderId not assignable to UserId
Result type & async error handling
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
return { ok: true, value: (await res.json()) as User };
} catch (error) {
// `error` is `unknown` (useUnknownInCatchVariables). Normalize — never `error as Error`,
// which yields an object whose `.message` is undefined when a non-Error is thrown.
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}
async function retry<T>(fn: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
// `unknown` + sentinel: avoids the unsafe cast AND the uninitialized hazard if attempts <= 0
let lastError: unknown = new Error("retry: no attempts made");
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (e) {
lastError = e instanceof Error ? e : new Error(String(e));
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs * 2 ** i));
}
}
throw lastError;
}
React + TypeScript
import { type ReactNode, type ComponentPropsWithoutRef, createContext, useContext, useState, useRef } from "react";
// Extend native element props instead of re-declaring them:
interface InputProps extends ComponentPropsWithoutRef<"input"> {
label: string;
error?: string;
}
// Generic component — T is inferred from `items`:
function List<T>({ items, render, keyOf }: {
items: T[]; render: (t: T) => ReactNode; keyOf: (t: T) => string | number;
}) {
return <ul>{items.map((it) => <li key={keyOf(it)}>{render(it)}</li>)}</ul>;
}
// Custom hooks: return `as const` so the tuple keeps positional types
function useToggle(init = false) {
const [on, setOn] = useState(init);
return [on, () => setOn((v) => !v)] as const; // [boolean, () => void], not (boolean | (() => void))[]
}
const ref = useRef<HTMLVideoElement>(null); // ref.current: HTMLVideoElement | null
// Context typed `T | undefined`; guard in the hook so consumers get a non-null value
const Ctx = createContext<AuthValue | undefined>(undefined);
function useAuth(): AuthValue {
const c = useContext(Ctx);
if (!c) throw new Error("useAuth must be used within AuthProvider");
return c;
}
- ⚠ Avoid
React.FC: it doesn't support generic components and itschildrensemantics shifted in React 18 types. Annotate the props object directly; addchildren: ReactNodeonly when the component renders children. - Polymorphic
asprop — the one genuinely tricky component type:
type Poly<C extends React.ElementType, P = {}> =
P & { as?: C } & Omit<React.ComponentPropsWithoutRef<C>, keyof P | "as">;
function Text<C extends React.ElementType = "span">({ as, ...rest }: Poly<C, { size?: "sm" | "lg" }>) {
const Tag = as ?? "span";
return <Tag {...rest} />; // <Text as="a" href="…"/> type-checks href
}
Node.js + Express
import type { Request, Response, NextFunction } from "express";
import { z } from "zod";
// Augment Request via declaration merging (see Declaration merging):
declare global {
namespace Express { interface Request { user?: User } }
}
// One validation-middleware factory, reused per route:
const validate = <T>(schema: z.ZodSchema<T>) =>
(req: Request, res: Response, next: NextFunction) => {
const r = schema.safeParse(req.body);
if (!r.success) return res.status(400).json({ errors: r.error.issues });
req.body = r.data; // now typed T for downstream handlers
next();
};
app.post("/users", validate(CreateUserSchema), createUserHandler);
// Model API failures as a discriminated union, mapped to status codes centrally:
type ApiError =
| { type: "validation"; error: z.ZodError }
| { type: "not_found"; resource: string }
| { type: "unauthorized"; message: string };
Common Anti-Patterns
// ❌ any bypasses checking → ✅ unknown + narrow
function bad(d: any) { return d.a.b.c; }
function good(d: unknown): string { if (isValid(d)) return d.a.b.c; throw new Error("invalid"); }
// ❌ assert across the runtime boundary → ✅ validate (zod), then trust the type
const u1 = JSON.parse(input) as User; // lies if the shape is wrong
const u2 = UserSchema.parse(JSON.parse(input));
// ❌ non-null assertion abuse → ✅ handle / throw explicitly
const a = users.find((u) => u.id === id)!; // crashes silently if missing
const b = users.find((u) => u.id === id) ?? throwMissing(id);
// ❌ overly-permissive object → ✅ generics preserve the type
function mergeBad(a: object, b: object): object { return { ...a, ...b }; }
function mergeGood<T extends object, U extends object>(a: T, b: U): T & U { return { ...a, ...b }; }
// ❌ numeric enum — `let s: Status = 999` compiles (assignment hole); also errors under
// --erasableSyntaxOnly and can't be stripped by Node's native TS (22.18+).
enum Status { Pending, Active }
// ✅ const object + union — zero runtime cost, no hole, strips cleanly, keeps exhaustiveness
const Status = { Pending: "pending", Active: "active" } as const;
type Status = (typeof Status)[keyof typeof Status];
// ❌ async callback in forEach (fire-and-forget, unhandled rejections)
items.forEach(async (i) => { await save(i); });
// ✅ await the whole batch
await Promise.all(items.map((i) => save(i)));
// ❌ boolean-flag state permits impossible combos (loading && error && data)
type S1 = { isLoading: boolean; data?: User[]; error?: Error };
// ✅ discriminated union: only valid states are representable
type S2 = { status: "loading" } | { status: "success"; data: User[] } | { status: "error"; error: Error };
Expert Practices: Idioms, Anti-Patterns & Gotchas
Idioms
satisfies — validate without widening
satisfies (TS 4.9+) checks that an expression is assignable to a target type without replacing the expression's inferred type with that target, so downstream code keeps the narrowest per-property/literal types while still catching wrong shapes and typo'd keys. It resolves the dilemma between a : Type annotation (validates but WIDENS, losing literal/tuple precision) and an as Type assertion (preserves nothing and SUPPRESSES mismatches, so a misspelled key slips through). Mechanism: TypeScript verifies assignability to the target but records the original expression type for inference. Constraint: it applies only at an expression/initializer site — it is not a statement you can retroactively apply to an already-declared variable.
type Colors = "red" | "green" | "blue";
type RGB = [number, number, number];
const palette = {
red: [255, 0, 0],
green: "#00ff00",
// bleu: [0, 0, 255] // ← Error: 'bleu' is not in Record<Colors, ...>
} satisfies Record<Colors, string | RGB>;
palette.green.toUpperCase(); // OK — still narrowed to string
palette.red.at(0); // OK — still narrowed to [number, number, number]
// An `as` assertion would suppress everything — a typo'd key compiles silently:
// const p = { red: [255,0,0], bleu: [0,0,255] } as Record<Colors, string | RGB>;
NoInfer<T> — mark a parameter validate-only
NoInfer<T> (TS 5.4+) tells TypeScript not to use a parameter as an inference candidate for a type variable, while still validating it against the T inferred from the principal parameters. Without it, every T-typed parameter contributes inference candidates, so a default value or callback can silently expand what T resolves to and accept out-of-range values. Use it when one parameter is the authoritative source of truth.
function createStreetLight<C extends string>(
colors: C[],
defaultColor?: NoInfer<C>, // validated against C, never widens it
) {}
createStreetLight(["red", "yellow", "green"], "blue");
// Error: '"blue"' is not assignable to '"red" | "yellow" | "green" | undefined'
// Without NoInfer, 'blue' would be absorbed into C and accepted.
const type parameters — keep the readonly constraint
The const modifier on a type parameter (TS 5.0+) makes inline literal arguments infer const-like (literal/tuple) types, so callers no longer need as const. The silent trap: if the constraint is mutable (T extends string[]), the const-inferred candidate readonly ['a','b'] is not assignable to it, so inference falls back to the widened mutable type with NO warning. Always use a readonly constraint. The modifier also affects only literals written directly at the call site — passing a pre-declared variable (already inferred as string[]) sees no benefit.
declare function tags<const T extends readonly string[]>(args: T): T;
const t = tags(["a", "b"]); // readonly ['a', 'b']
// With `T extends string[]` (mutable), tags(['a','b']) silently widens to string[].
verbatimModuleSyntax and precise type-only imports
verbatimModuleSyntax (TS 5.0+) replaces the deprecated, now-no-op importsNotUsedAsValues/preserveValueImports with one rule: imports/exports WITHOUT a type modifier are emitted verbatim; anything WITH type is erased. So every purely-type import must be import type { ... } or use an inline type specifier — otherwise it is emitted as a runtime import even when unused, defeating tree-shaking, forcing unwanted CJS/ESM require() inclusion, and breaking cross-compiler consistency (Oxc/esbuild/swc/Babel all strip type-marked imports reliably). It is in TS 5.9's tsc --init defaults. (See Modules above.)
using / await using — deterministic cleanup (TS 5.2)
Explicit Resource Management: any object implementing Symbol.dispose can be declared with using, and TypeScript guarantees dispose runs on scope exit — including early returns and exceptions — in last-in-first-out order. await using calls and awaits Symbol.asyncDispose. This replaces error-prone try/finally cleanup for DB connections, file handles, timers, and test fixtures. Requires lib to include esnext.disposable; some runtimes need a Symbol.dispose polyfill.
class DbConnection implements Disposable {
constructor(private conn: Connection) {}
[Symbol.dispose]() {
this.conn.close();
}
}
async function processRecords() {
using db = new DbConnection(openConnection());
return await db.conn.query("SELECT * FROM records"); // close() runs on every exit path
}
// tsconfig: { "lib": ["es2022", "esnext.disposable"] }
Anti-Patterns
Explicit x is T predicates are trusted unconditionally — as unsafe as as
When you annotate a guard's return type as x is T, TypeScript does NOT verify the body actually narrows x to T — it trusts the assertion, making an explicit predicate semantically equivalent to a type assertion. A wrong or incomplete predicate compiles silently and causes runtime type confusion. Prefer letting TypeScript INFER the predicate from a simple narrowing body (TS 5.5+), because then the compiler derives it from the implementation. Reserve explicit is for cases inference cannot handle (multiple return paths, deep structural validation) — write them thoroughly and unit-test them.
// Inferred & validated by the compiler:
const isString = (x: unknown) => typeof x === "string"; // inferred: x is string
// Compiles fine but is a lie — TypeScript never checks the body:
function isPositive(n: number): n is 1 | 2 | 3 {
return n > 0; // also true for 4, 5, … → downstream runtime crash
}
Never publish const enum in a .d.ts
A published const enum is inlined into consumers' bundles at compile time. If a later patch changes member values, consumers keep the OLD inlined values while running the NEW library — a silent wrong-branch bug. It is also incompatible with isolatedModules and single-file transpilers (Oxc, Babel, esbuild, swc), which cannot inline cross-file values. For published APIs use a regular enum, an as const object, or preserveConstEnums to strip the const from declaration output.
// Safe in a published package — value exists at runtime, no inlining:
const Direction = { Up: "UP", Down: "DOWN" } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
Gotchas
TS 5.5 inferred type predicates — but truthiness and .filter(Boolean) do NOT narrow
TS 5.5 infers a type predicate for a function with no explicit return annotation, a single return statement, no parameter mutation, and a boolean expression tied to a refinement of the parameter — so arr.filter(x => x !== undefined) finally returns T[]. The trap: truthiness checks (x => !!x, x => x) and .filter(Boolean) do NOT infer a predicate. Reason — the "if and only if" rule: !!score being false could mean undefined OR the valid value 0, so score is number would be unsound; Boolean is also not itself recognized as a predicate. The result is doubly bad: the type stays (T | undefined)[] AND zero/empty-string values are silently dropped at runtime. Use explicit comparisons or a named guard.
// Inferred predicate → Bird[]
const birds = countries.map((c) => birdMap.get(c)).filter((b) => b !== undefined);
// Reusable named guard for .filter(Boolean) situations:
function isDefined<T>(x: T | null | undefined): x is NonNullable<T> {
return x != null;
}
const defined = countries.map((c) => birdMap.get(c)).filter(isDefined); // Bird[]
// BAD: type stays (number | undefined)[] AND zero scores are dropped:
// students.map(s => scoreMap.get(s)).filter(score => !!score);
useUnknownInCatchVariables types catch as unknown (on via strict since 4.4)
Catch-clause variables are unknown, not any, so touching .message/.stack without a guard fails to compile — and the break rides in silently via strict on upgrade. Use an instanceof Error guard; error as Error restores the old unsafe behavior and is only a temporary migration crutch (see the fetchUser/retry examples above).
try {
await riskyOperation();
} catch (err) {
if (err instanceof Error) console.error(err.message);
else console.error("Unknown error:", String(err));
}
Excess-property checking only fires on FRESH object literals
The "may only specify known properties" error fires only when a literal is assigned DIRECTLY to a typed target or passed DIRECTLY as an argument. Assigning the same literal to an intermediate variable first — even one with an explicit type annotation — strips its freshness, and structural typing then allows the extra properties silently. Refactoring a direct literal into a named variable "for readability" can suppress a real bug the compiler was catching.
interface Duck {
quack(): void;
}
const d: Duck = { quack() {}, woof() {} }; // Error: 'woof' is excess on a fresh literal
const obj = { quack() {}, woof() {} }; // freshness lost
const d2: Duck = obj; // No error — extra 'woof' silently allowed
Control-flow narrowing is discarded inside closures — copy to a const
TypeScript drops a variable's narrowing when it is captured by a closure, even if unconditionally assigned beforehand, because the captured binding could be reassigned between narrowing and execution (acknowledged design limitation, microsoft/TypeScript#37802). Copy the narrowed value into a fresh const so the closure captures an immutable binding.
function deferred(value?: string): () => string {
if (value == null) value = "";
const v = value; // const captures the narrowed type
return () => v; // v is string — returning () => value would widen to string | undefined
}
Method-shorthand syntax is checked bivariantly — strictFunctionTypes does not catch it
strictFunctionTypes enforces contravariant parameter checking for function-TYPED properties (m: (x: T) => void), but the docs explicitly exempt parameters of methods declared in shorthand syntax (m(x: T): void) — these stay BIVARIANT. The exemption lets Array<T> relate covariantly, but in user-defined interfaces it is a real soundness hole. oxlint's typescript/method-signature-style set to "property" forces property syntax to close the gap.
interface Processor {
process: (value: string | number) => void; // property syntax → contravariant
}
const p: Processor = {
process: (value: string) => console.log(value), // Error: string not assignable to string | number
};
// With method shorthand `process(value: string | number): void`, the same
// assignment compiles — and crashes at runtime if called with a number.
Template-literal types produce Cartesian products
A template-literal type interpolating multiple unions expands to the full Cartesian product: unions of size N and M yield N*M members, growing multiplicatively and becoming a real compile-time cost for large schemas. The bounded event-name pattern is the canonical good use; for large route maps or i18n keys prefer code generation (tsc --generateTrace surfaces the cost). Note the intrinsic Uppercase/Lowercase/Capitalize types use raw JS toUpperCase/toLowerCase — they are NOT locale-aware.
type PropEventSource<T> = {
on<K extends string & keyof T>(event: `${K}Changed`, cb: (v: T[K]) => void): void;
};
// AVOID: `${Methods} ${Routes}` over 5 × 50 unions → 250 members; prefer codegen.
Performance
Prefer interface extends over type intersection for object types
interface Foo extends Bar, Baz produces a single flat object type whose relationships the compiler caches, whereas type Foo = Bar & Baz forces a recursive merge of constituents on every comparison at each use site. Effects: faster type-checking / better language-server responsiveness in large codebases; conflicting properties are reported eagerly at the declaration instead of silently collapsing to never at use sites; cleaner IDE hover. The TS Performance wiki names this a high-impact optimization. Intersections remain necessary for composing non-object types (unions, primitives, mapped/conditional results).
interface AdminUser extends BaseUser, AdminPermissions {
adminLevel: number;
}
// type AdminUser = BaseUser & AdminPermissions & { adminLevel: number }; ← recomputed per comparison
isolatedDeclarations unlocks parallel .d.ts emit
isolatedDeclarations (TS 5.5+) requires explicit type annotations on all exported symbols so each file's .d.ts can be generated independently, without a whole-program type-checker pass — letting tools (Oxc, esbuild, swc) emit declarations in parallel and removing the monorepo serialization bottleneck. Requires declaration or composite. Tradeoff: explicit return types on exported functions become mandatory; it pays off most when you already enforce typescript/explicit-function-return-type in oxlint, and it is what lets tsdown emit .d.ts per file instead of running a whole-program pass.
// isolatedDeclarations: true (with declaration: true)
export function computeTotal(items: Item[]): number {
return items.reduce((sum, i) => sum + i.price, 0);
}
Currency
Import attributes: with { type: 'json' }, not assert
Import assertions using the withdrawn assert keyword were superseded by import attributes using with (ES2025). Under --module nodenext, TS 5.8 makes assert a hard error (matching Node.js 22+), and TS 5.7 already required with for validated JSON imports under nodenext. Migrate all assert { type: 'json' } to with { type: 'json' }.
import config from "./config.json" with { type: "json" };
Compiler migrations: inspect the installed release notes
Compiler defaults and deprecated module-resolution modes change across TypeScript releases. Before changing target, moduleResolution, baseUrl, or path aliases, inspect the release notes for the installed and target compiler, then test the emitted package from a consumer. Prefer standards-based package exports/imports maps for new Node aliases, but do not mechanically replace an established bundler alias without confirming runtime and test-runner support.
{ "imports": { "#utils/*": "./src/utils/*.js", "#models/*": "./src/models/*.js" } }
Checklist: type-safety review before done
- [ ] No
anyin changed code (search it); external inputs typedunknownand narrowed - [ ] Runtime boundaries (HTTP/JSON/env/DB) validated with a schema, not
as— parse inputs typedz.input, resultsz.infer - [ ] Every
switchover a union ends inassertNever(x); discriminants are string-literal unions, not numeric enums - [ ]
catchvariables guarded withinstanceof Error(noterror as Error) - [ ] No non-null
!on lookups that can miss; optional chaining / explicit throw instead - [ ] Public API types use
interface extends(not&); noconst enumin shipped.d.ts - [ ] Type guards prefer inferred predicates; explicit
x is Tguards are unit-tested - [ ]
tsc --noEmitclean understrict+noUncheckedIndexedAccess+exactOptionalPropertyTypes - [ ]
oxlint --deny-warningsandoxfmt --checkclean; no ESLint/Prettier/esbuild/Babel added to a project on the Bun + Vite + Oxc stack