Installs into .claude/skills of the current project.
Are you the author of Loom Typescript?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-typescript)
---
name: loom-typescript
description: TypeScript language expertise for type-safe, production-quality code.
triggers:
- typescript
- ts
- tsx
- interface
- generic
- union
- intersection
- discriminated union
- type guard
- type assertion
- utility types
- conditional types
- mapped types
- satisfies
- zod
- trpc
- prisma
- react
- node
- nodejs
- deno
- bun
- oxc
- oxlint
- oxfmt
- rolldown
- tsdown
- vite
- eslint
- prettier
- npm
- pnpm
- yarn
- type-safe
- type safety
- tsconfig
- strict mode
- branded types
---
# 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
```typescript
// 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`) |
```typescript
// 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`
```typescript
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
```typescript
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.
```typescript
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`
- `any` disables checking **transitively** — it silently poisons every expression it flows into. `unknown` is the safe top type: assignable *from* anything, assignable *to* nothing until you narrow.
- Type external inputs (`JSON.parse`, `fetch().json()`, `catch` vars, `process.env` shapes) as `unknown` and narrow with `typeof` / `instanceof` / `in` / a validator before use.
```typescript
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.
```json
{
"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).
```json
{
"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 `| undefined` to array subscripts and index-signature access (`arr[i]: T | undefined`). ⚠ NOT applied to named properties, NOT to `for...of` loop variables (by design, microsoft/TypeScript#42622), NOT to `Object.values()` — so it's no safety net when iterating.
- **`exactOptionalPropertyTypes`** — `obj.x = undefined` becomes an error for `x?: "a" | "b"`; only *deleting* the key makes the property absent. Matters for `"x" in obj` checks and serialization round-trips.
- **`useUnknownInCatchVariables`** (on via `strict` since 4.4) — `catch` vars are `unknown`; guard with `instanceof Error`.
- **`verbatimModuleSyntax`** — modern module-safety baseline; supersedes the now-no-op `importsNotUsedAsValues`/`preserveValueImports` (see Modules).
- **`isolatedModules`** — forbids constructs single-file transpilers can't handle (re-exporting a type without `type`, `const enum` inlining); required for single-file transpilers — Oxc (Vite, tsdown), Bun's transpiler, esbuild, swc, Babel.
### Checklist: tsconfig
- [ ] Exactly one of `nodenext` / `bundler` chosen to match the runtime — not mixed
- [ ] `strict: true` PLUS `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` (they aren't in `strict`)
- [ ] `verbatimModuleSyntax: true`; type-only imports carry the `type` modifier
- [ ] Library build: `declaration: true`, NOT `moduleResolution: bundler`, no `const enum` in public `.d.ts`
- [ ] `isolatedModules: true` if any non-`tsc` transpiler 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).
```bash
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:
```json
{
"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; only `correctness` is on by default — opt into `suspicious`, `perf`, `pedantic` per project rather than `all`.
- Setting `plugins` REPLACES the default set (`eslint`, `typescript`, `unicorn`, `oxc`) — list the defaults you keep alongside the ones you add (`import`, `promise`, `vitest`, `react`, `jsx-a11y`, ...).
- `oxlint --fix` applies only safe fixes; `--fix-suggestions` and `--fix-dangerously` can change behavior — review the diff.
- Type-aware rules (`typescript/no-floating-promises`, `typescript/no-unsafe-assignment`, ...) need `oxlint-tsgolint` plus `"options": { "typeAware": true }` (or `oxlint --type-aware`). They run on the native TypeScript compiler (tsgo), so the tsconfig must be tsgo-clean — no `baseUrl` — and `include` should stay tight or the pass gets slow.
- Migrating from ESLint: `bunx -p @oxlint/migrate oxlint-migrate` converts a flat config to `.oxlintrc.json`. If a rule oxlint lacks must stay, keep ESLint for that rule only, add `eslint-plugin-oxlint` to turn off everything oxlint already covers, and run `oxlint && eslint` so the fast pass fails first.
```json
{
"$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: `oxfmt` writes in place, `oxfmt --check` for CI. Config is `.oxfmtrc.json`; it honors `.gitignore` and `.prettierignore` by default and formats TS/JS/JSX/TSX, JSON, CSS, Markdown, YAML and more.
- Default `printWidth` is 100 (Prettier's is 80). Matching an existing Prettier codebase: generate the config with `oxfmt --migrate=prettier` or set `printWidth` explicitly, then land one formatting-only commit.
- `sortPackageJson` is on by default; `sortImports` is opt-in. Drop Prettier and its ESLint bridges (`eslint-config-prettier`, `eslint-plugin-prettier`) — oxlint and oxfmt do not overlap under the default categories (`style` is 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:
```bash
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`:
```typescript
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.
```yaml
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.
```typescript
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.
```typescript
// 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 `interface`s 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
```typescript
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
```typescript
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
```typescript
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.
```typescript
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
```typescript
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
```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 its `children` semantics shifted in React 18 types. Annotate the props object directly; add `children: ReactNode` only when the component renders children.
- Polymorphic `as` prop — the one genuinely tricky component type:
```typescript
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
```typescript
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
```typescript
// ❌ 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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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).
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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).
```typescript
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.
```typescript
// 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' }`.
```typescript
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.
```json
{ "imports": { "#utils/*": "./src/utils/*.js", "#models/*": "./src/models/*.js" } }
```
## Checklist: type-safety review before done
- [ ] No `any` in changed code (search it); external inputs typed `unknown` and narrowed
- [ ] Runtime boundaries (HTTP/JSON/env/DB) validated with a schema, not `as` — parse inputs typed `z.input`, results `z.infer`
- [ ] Every `switch` over a union ends in `assertNever(x)`; discriminants are string-literal unions, not numeric enums
- [ ] `catch` variables guarded with `instanceof Error` (not `error as Error`)
- [ ] No non-null `!` on lookups that can miss; optional chaining / explicit throw instead
- [ ] Public API types use `interface extends` (not `&`); no `const enum` in shipped `.d.ts`
- [ ] Type guards prefer inferred predicates; explicit `x is T` guards are unit-tested
- [ ] `tsc --noEmit` clean under `strict` + `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes`
- [ ] `oxlint --deny-warnings` and `oxfmt --check` clean; no ESLint/Prettier/esbuild/Babel added to a project on the Bun + Vite + Oxc stack