cosmix/loom

loom-typescript

TypeScript language expertise for type-safe, production-quality code.

First seen May 20, 2026

Installation

$ npx skills add cosmix/loom --skill loom-typescript

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from cosmix/loom · top by installs.

npx skills add cosmix/loom

Browse all from cosmix/loom

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 53
License LICENSE
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 40,758 B
  • docs SUMMARY.md 92 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 6 installs

SKILL.md

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

  • 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.
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 | 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.
  • exactOptionalPropertyTypesobj.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).

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; 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.
{
  "$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.

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 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:
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 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