karthik.dev
Back to blogEngineering

TypeScript Patterns I Use Every Day (And Why They Matter)

2026-05-02 · 6 min read

TypeScript's type system is one of the most expressive in any mainstream language. Most tutorials cover the basics. Here are the patterns I reach for constantly that go slightly deeper.

Discriminated unions for state machines

Any time you have a value that can be in multiple states with different data per state, a discriminated union is cleaner than a nullable-field soup:

// ❌ Don't do this
type FetchResult = {
  status: "loading" | "success" | "error";
  data?: User;
  error?: string;
};

// ✅ Do this
type FetchResult =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

With the discriminated union, TypeScript knows that when status === "success", data is guaranteed to exist. No optional chaining needed.

Const assertions for literal inference

When you need an array of string literals that TypeScript should treat as the actual values, not as string[]:

const ROLES = ["admin", "student", "mentor", "recruiter", "faculty"] as const;
type Role = (typeof ROLES)[number];
// Type: "admin" | "student" | "mentor" | "recruiter" | "faculty"

This lets you derive your union type from the source of truth rather than maintaining them separately.

Template literal types for API route typing

For typed API endpoints:

type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIRoute = `/api/${string}`;
type Endpoint = `${HTTPMethod} ${APIRoute}`;

// Valid: "GET /api/users"
// Invalid: "PATCH /api/users" (PATCH not in HTTPMethod)

I use this pattern in CodeMyFYP Academy to generate typed route constants that fail at compile time if you construct an invalid endpoint string.

The satisfies operator

Introduced in TypeScript 4.9, satisfies validates that a value conforms to a type while preserving the literal types of the value itself:

const palette = {
  red: "#ef4444",
  green: "#22c55e",
  blue: "#3b82f6",
} satisfies Record<string, string>;

// palette.red is type `"#ef4444"` (literal), not `string`
// But you get an error if you add a non-string value

This is particularly useful for config objects where you want both type safety and autocomplete on literal values.

Branded types for value-object semantics

For domain values that are the same underlying type but should not be mixed:

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

function createUserId(id: string): UserId {
  return id as UserId;
}

// This fails at compile time:
function getUser(userId: UserId): User { ... }
getUser(tenantId); // Type error!

This catches entire classes of bugs where you accidentally pass the wrong string ID to a function.

The pattern that made TypeScript click for me

Stop trying to annotate everything manually. Let TypeScript infer as much as possible, and only annotate at API boundaries (function parameters, return types of public functions, exported types). The type system is most useful when it catches errors at the call site — and it can only do that if the inference chain is unbroken.

Every time you write as any or as unknown, you're cutting the inference chain. The discipline of refusing to reach for type assertions has, for me, been the single biggest source of TypeScript-caught bugs.