TypeScript Best Practices: Beyond the Basics


Disclosure: This article may contain affiliate links. We only recommend products we believe in. See how we make money.

TypeScript is a contract with the compiler, not a compliment you pay the codebase. any is how you tear the contract up. The rest of this page is the patterns that keep the contract useful once strict is already on.

If the types sit on an HTTP boundary, pair them with the rate-limiting and security notes — a perfect type on an unparsed req.body is fan fiction. The git workflow is where npm run type-check belongs (CI, not a hope).

Stop using any (use unknown, then narrow)

any disables checking on that value and on everything derived from it. unknown is the honest “I have not looked yet.”

// Bad — compiles, throws at runtime if `name` is missing
function processData(data: any) {
  return data.name.toUpperCase();
}

function processData(data: unknown) {
  if (typeof data === "object" && data !== null && "name" in data) {
    const { name } = data as { name: unknown };
    if (typeof name === "string") return name.toUpperCase();
  }
  throw new Error("Invalid data format");
}

Prefer a Zod (or similar) parse at the edge so the rest of the function sees a real User, not a handmade guard.

Discriminated unions for state machines

A status field the compiler can switch on is how you stop data! and error!.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

function renderUsers(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Click to load";
    case "loading":
      return "Loading...";
    case "success":
      return state.data.map((u) => u.name); // data exists
    case "error":
      return `Error: ${state.error}`; // error exists
  }
}

Do not model this as { status: string; data?: User[]; error?: string }. That type allows { status: "success" } with no data.

as const and satisfies

const colors = ["red", "green", "blue"] as const;
type Color = (typeof colors)[number]; // "red" | "green" | "blue"

const routes = {
  home: "/",
  article: "/blog/",
} as const satisfies Record<string, `/${string}`>;

as const freezes literals. satisfies checks the value against a type without widening it to that type — you keep the narrow keys. Official TS 4.9 notes describe satisfies for exactly this “check, don’t widen” job.

The tsconfig flags after strict

strict is the floor. Two flags still leak through on otherwise-strict codebases:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "skipLibCheck": true
  }
}
  • noUncheckedIndexedAccess. arr[0] becomes T | undefined. That is correct. The alternative is a production TypeError on an empty list.
  • exactOptionalPropertyTypes. { name?: string } no longer accepts { name: undefined } unless you wrote that. Optional and undefined-able are different.
  • noImplicitOverride. override on methods so a rename in the base class breaks the child.
  • skipLibCheck. You almost always want this; it skips type-checking .d.ts you do not own.

Run tsc --noEmit in CI. An editor-only check is a check people skip.

Utility types — use them, do not nest them

  • Partial<T> for a PATCH body you will still parse.
  • Pick<T, K> / Omit<T, K> for public vs stored shapes.
  • Record<K, V> for maps with known keys.
  • ReturnType<T> / Parameters<T> when wrapping a function you do not control.

Partial<Pick<Omit<Foo, "id">, "a" | "b">> is a cry for a named type. If an AI assistant generated a four-level utility pile, rewrite it before you merge.

Branded ids when string is a lie

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getOrder(id: OrderId): Promise<Order> {
  return db.order(id);
}

// getOrder(userId) is a type error — which is the point

This does not validate at runtime. Pair it with a parse at the boundary (UserId.parse(req.params.id) or a Zod brand).

Zod (or friends) at the runtime edge

Types disappear. fetch, process.env, req.body, and JSON.parse all return the same lie. Zod is one library that both validates and infers the type so they cannot drift:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive(),
});

type User = z.infer<typeof UserSchema>;

const parsed = UserSchema.safeParse(await response.json());
if (!parsed.success) {
  throw new Error(parsed.error.message);
}
// parsed.data is User

Do this once at the edge. Do not sprinkle as User through the app. The same parse belongs on webhook payloads you did not write.

What not to do

  • as to silence a red underline. Fix the type or parse. as is a last resort for a typed hole in a library, not a habit.
  • enum for a closed string set. A union ("idle" | "loading") or as const object is enough and emits less runtime JS. Keep const enum out of libraries.
  • Huge interface merges from AI. If the model invented fields the API does not have, that is a hallucination, not a type.

Ship strict, parse the boundary, and keep the unions discriminated. The rest is taste.