Agent Skills: TypeScript Language Expertise

TypeScript language expertise for type-safe, production-quality code. Use for advanced type system features (generics, discriminated unions, conditional and mapped types), strict mode configuration, type-safe APIs with zod/trpc/prisma, and modern tooling across Node, Deno, and Bun.

UncategorizedID: cosmix/claude-loom/loom-typescript

Install this agent skill to your local

pnpm dlx add-skill https://github.com/cosmix/loom/tree/HEAD/skills/loom-typescript

Skill Files

Browse the full folder contents for loom-typescript.

Download Skill

Loading file tree…

skills/loom-typescript/SKILL.md

Skill Metadata

Name
loom-typescript
Description
TypeScript language expertise for type-safe, production-quality code. Use for advanced type system features (generics, discriminated unions, conditional and mapped types), strict mode configuration, type-safe APIs with zod/trpc/prisma, and modern tooling across Node, Deno, and Bun.

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.

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, esbuild, webpack)

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.

{
  "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 esbuild/swc/Babel pipelines.

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

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 (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 (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. The typescript-eslint rule method-signature-style can force 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 explicit-return-types via ESLint.

// 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" };

TS 6.0 deprecates / TS 7.0 (Go compiler) removes es5 target, baseUrl, node10 resolution

TS 6.0 is the LAST JavaScript-based release; it DEPRECATES, and the Go-rewritten TS 7.0 REMOVES: --target es3/es5 (ES2015 becomes the minimum), --baseUrl (migrate path aliases to the Node-native package.json#imports map, supported by both Node and TS without a build step), and moduleResolution: node10/classic. Down-leveling for ancient targets belongs in Babel/esbuild, not tsc. The deprecations land in 6.0; the removals in 7.0 — do not conflate the two.

{ "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