Writing
TypeScriptReactPatterns

TypeScript Patterns I Reach For Every Day in React

After 4+ years of TypeScript in production React codebases, these are the patterns I install on day one of every new project — from discriminated unions to template literal types.

May 12, 2024·9 min read

TypeScript Patterns I Reach For Every Day in React

TypeScript is only as good as how you model your domain. Here are the patterns I install on every new React project.

1. Discriminated Unions for Component Variants

Instead of a grab-bag of optional props, use discriminated unions to make illegal states unrepresentable:

type ButtonProps =
  | { variant: "primary"; onClick: () => void }
  | { variant: "link"; href: string }
  | { variant: "submit" };

function Button(props: ButtonProps & { children: React.ReactNode }) {
  if (props.variant === "link") {
    return <a href={props.href}>{props.children}</a>;
  }
  // ...
}

Now TypeScript enforces that a "link" button MUST have href, not just optionally.

2. The satisfies Operator for Config Objects

satisfies lets you get inference AND validation without widening the type:
const ROUTES = {
  home: "/",
  blog: "/blog",
  about: "/about",
} satisfies Record<string, string>;

type Route = typeof ROUTES; // { home: string; blog: string; about: string }
type RouteName = keyof typeof ROUTES; // "home" | "blog" | "about"

3. Generic Fetch with Full Type Safety

async function api<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, options);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

// Usage — T is inferred from the generic parameter
const user = await api<User>("/api/user/1");

4. Template Literal Types for Event Names

When you have a system-wide event bus or analytics:

type Module = "auth" | "dashboard" | "checkout";
type Action = "view" | "click" | "submit" | "error";
type EventName = `${Module}:${Action}`;

// "auth:view" | "auth:click" | "dashboard:view" | ... (12 combinations, all typed)
function track(event: EventName, data?: Record<string, unknown>) { ... }

track("auth:click"); // ✅
track("payment:click"); // ❌ Type error

5. Branded Types for IDs

Stop passing the wrong ID to the wrong function:

type UserId = string & { readonly _brand: "UserId" };
type PostId = string & { readonly _brand: "PostId" };

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

function getUser(id: UserId) { ... }
function getPost(id: PostId) { ... }

const userId = createUserId("abc");
const postId = "xyz" as PostId;

getUser(postId); // ❌ Type error — postId is not UserId

These five patterns consistently prevent entire categories of bugs and make code reviews much easier because intent is encoded directly in the types.