DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Advanced TypeScript Patterns with Claude Code: Utility Types, Discriminated Unions, and Type Guards

Using any is the fast path to eliminating everything TypeScript is supposed to give you. Type-inferred code is self-documenting and catches bugs at refactor time — not in production at 2am.

Claude Code, guided by CLAUDE.md rules, generates advanced type designs from day one. Here's the exact setup and the patterns it produces.


Step 1: Lock Down Claude Code with CLAUDE.md

## TypeScript Strict Rules

- No `any` — use `unknown` + type guards instead
- No `as T` assertions — implement type guard functions
- No `// @ts-ignore` or `// @ts-expect-error`
- External API responses: Zod parse, not type assertion
- State modeled as Discriminated Union
- Template Literal Types for event names and map keys
- Use `satisfies` for type-checked config objects
- tsconfig: `"strict": true, "noUncheckedIndexedAccess": true`
Enter fullscreen mode Exit fullscreen mode

Write it once. Every Claude Code session follows it automatically.


Pattern 1: Discriminated Union for API State

Instead of isLoading: boolean + data: T | null + error: string | null scattered across your component, use a single union type that's impossible to get into an inconsistent state.

type ApiState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string; retryCount: number };

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

function renderUserList(state: ApiState<User[]>): string {
  switch (state.status) {
    case "idle":
      return "Waiting for request...";
    case "loading":
      return "Loading users...";
    case "success":
      return state.data.map(u => u.name).join(", ");
    case "error":
      return `Error (retry ${state.retryCount}): ${state.error}`;
    // TypeScript enforces exhaustive handling — no default needed
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a new variant to ApiState and the compiler will point out every unhandled branch. That's free regression coverage.


Pattern 2: Template Literal Types for Event Systems

Hard-coded string events like "user-created" or "order_updated" cause silent bugs when they're mistyped. Template Literal Types make the compiler catch them.

type Entity = "user" | "order" | "product";
type Action = "created" | "updated" | "deleted";

// Generates all 9 combinations at the type level
type EventName = `${Entity}.${Action}`;
// "user.created" | "user.updated" | "user.deleted" |
// "order.created" | ... | "product.deleted"

interface UserCreatedPayload { userId: string; email: string }
interface OrderUpdatedPayload { orderId: string; status: string }
interface ProductDeletedPayload { productId: string }

type EventPayloads = {
  "user.created":    UserCreatedPayload;
  "order.updated":   OrderUpdatedPayload;
  "product.deleted": ProductDeletedPayload;
  // Add more as needed — compiler enforces completeness
};

class TypedEventEmitter {
  emit<K extends keyof EventPayloads>(
    event: K,
    payload: EventPayloads[K]
  ): void {
    console.log(event, payload);
  }
}

const emitter = new TypedEventEmitter();

// Correct — compiles fine
emitter.emit("user.created", { userId: "u1", email: "a@b.com" });

// Wrong event name — compile error
// emitter.emit("usr.created", { ... });

// Wrong payload shape — compile error
// emitter.emit("user.created", { orderId: "o1", status: "paid" });
Enter fullscreen mode Exit fullscreen mode

No runtime event registry, no magic strings, no silent failures.


Pattern 3: satisfies for Type-Checked Config

satisfies lets you validate that an object conforms to a type while preserving the literal types of its values.

type RouteConfig = {
  path: string;
  title: string;
  auth: boolean;
};

// satisfies validates the shape WITHOUT widening types to RouteConfig
const routes = {
  home:    { path: "/",        title: "Home",     auth: false },
  profile: { path: "/profile", title: "Profile",  auth: true  },
  admin:   { path: "/admin",   title: "Admin",    auth: true  },
} satisfies Record<string, RouteConfig>;

// Derived type — literal string union from the actual keys
type RouteName = keyof typeof routes;
// "home" | "profile" | "admin"

// Type-safe navigation helper
function navigate(route: RouteName): void {
  const config = routes[route]; // RouteConfig, fully typed
  console.log(`Navigating to ${config.path}`);
}

navigate("profile"); // OK
// navigate("settings"); // Error: not a valid route
Enter fullscreen mode Exit fullscreen mode

Pattern 4: unknown + Type Guard for API Responses

Never trust external data. unknown forces you to validate before use; type guard functions centralize the validation logic.

import { z } from "zod";

const UserSchema = z.object({
  id:    z.string().uuid(),
  name:  z.string().min(1),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

// Type guard using Zod parse
function isUser(value: unknown): value is User {
  return UserSchema.safeParse(value).success;
}

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const raw: unknown = await res.json(); // unknown, not any

  const parsed = UserSchema.safeParse(raw);
  if (!parsed.success) {
    throw new Error(`Invalid user shape: ${parsed.error.message}`);
  }
  return parsed.data; // User — fully validated
}
Enter fullscreen mode Exit fullscreen mode

If the API changes its response shape, you get a runtime error with a clear message instead of silently corrupting downstream state.


Pattern 5: Utility Type Composition

Claude Code composes standard utility types to generate precise, minimal types from existing ones.

interface Product {
  id:          string;
  name:        string;
  price:       number;
  description: string;
  createdAt:   Date;
  updatedAt:   Date;
}

// Immutable product card — only display fields, all readonly
type ProductCard = Readonly<Pick<Product, "id" | "name" | "price">>;

// Update form — all fields optional except id, no timestamps
type ProductUpdateInput = Partial<Omit<Product, "id" | "createdAt" | "updatedAt">> & {
  id: string;
};

// Extract async return type
async function loadProducts(): Promise<Product[]> {
  return fetch("/api/products").then(r => r.json());
}

type LoadedProducts = Awaited<ReturnType<typeof loadProducts>>;
// Product[]
Enter fullscreen mode Exit fullscreen mode

No duplication. When Product changes, all derived types update automatically.


Summary

Pattern What It Prevents
Discriminated Union (ApiState) Impossible states like isLoading: true + data: [...]
Template Literal Types Mistyped event names, missing payload handlers
unknown + Zod type guard Silent API shape drift becoming runtime corruption
satisfies Config typos and invalid route names
Utility type composition Type duplication and manual sync when interfaces change

Define the constraints in CLAUDE.md. Claude Code applies them to every generation without reminders.


Code Review Pack (¥980) includes /code-review prompt for detecting any usage, unsafe as assertions, and missing type guards across your entire codebase. 👉 https://prompt-works.jp

Top comments (0)