TypeScript Rules

TypeScript is non-negotiable for me. Plain JS only happens when the platform forces it (a config file, a tiny script). Everywhere else, strict TypeScript.


Always start with strict mode

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true
  }
}

strict: true is the floor, not the ceiling. The extras above catch the bugs that strict misses — indexed access returning undefined, missed override keywords, fall-through case blocks.


any is a code smell, not a tool

If you must use any, you owe a comment explaining why and a TODO to remove it. In 90% of cases, the right escape hatch is unknown plus a narrowing check:

// Bad — silently allows anything downstream
function parse(raw: any) { return raw.user.name; }

// Good — forces the caller to narrow before use
function parse(raw: unknown): string {
  if (
    typeof raw === "object" && raw !== null &&
    "user" in raw && typeof (raw as any).user?.name === "string"
  ) {
    return (raw as { user: { name: string } }).user.name;
  }
  throw new Error("Invalid shape");
}

For external data (API responses, form input, env vars), I reach for Zod rather than hand-rolled guards.


Prefer type for unions, interface for objects you extend

It’s a soft rule, but it pays off in error messages:

type Status = "idle" | "loading" | "success" | "error";

interface User {
  id: string;
  name: string;
  email: string;
}

interface AdminUser extends User {
  permissions: string[];
}

Types live with the feature

I don’t keep a global types/ folder full of unrelated types. Each feature owns its own @types/ directory:

features/auth/
├── @types/
│   ├── user.ts
│   ├── session.ts
│   └── index.ts

Shared cross-cutting types (API envelope, pagination, etc.) go in global/@types/.


Inference > annotation, except at boundaries

I let TypeScript infer return types inside a feature, but I annotate public function signatures and component prop types. The reasoning: explicit types at boundaries are documentation; explicit types in internals are noise.

// Internal helper — let inference do its job
const computeTotal = (items: CartItem[]) =>
  items.reduce((sum, it) => sum + it.price * it.qty, 0);

// Public — annotate the boundary
export function createCart(items: CartItem[]): Cart {
  return { items, total: computeTotal(items) };
}

Discriminated unions for state

Loading states are unions, not flag combinations. Three booleans (isLoading, isError, isSuccess) is a bug factory.

type Query<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T };

The compiler then forces you to handle every branch — and data only exists in the success case, where you actually have it.