DEV Community

myougaTheAxo
myougaTheAxo

Posted on

TypeScript Advanced Patterns with Claude Code: No More `any`

Using any in TypeScript is a trap. It silences the compiler, hides bugs until runtime, and spreads like a virus — once you allow it in one place, it shows up everywhere.

Claude Code, with the right setup, generates TypeScript that never touches any. Here's the exact workflow using three battle-tested patterns.


Step 1: Lock Down Claude Code with CLAUDE.md Rules

Claude Code reads CLAUDE.md from your project root as persistent context. Define your TypeScript constraints there and every code generation session automatically follows them.

## TypeScript Strict Rules

- `tsconfig.json` must have `"strict": true`
- `any` is banned — use `unknown` and narrow with type guards
- `as` type assertions are banned — implement type guard functions instead
- `// @ts-ignore` and `// @ts-expect-error` are banned
- All functions must have explicit return type annotations
- External API responses must be typed as `unknown`, validated with zod or type guards
Enter fullscreen mode Exit fullscreen mode

Write it once, enforce it forever.


Pattern 1: Discriminated Union + Result Type

The classic try/catch problem:

// This is what we want to avoid
async function fetchUser(id: string) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json(); // any
  } catch (e: any) { // any again
    console.error(e.message);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Prompt for Claude Code:

Following the CLAUDE.md TypeScript rules, implement a Result<T, E> type for
type-safe error handling:
- ok: true branch holds data: T
- ok: false branch holds error: E
- Include unwrapOrThrow(), mapResult(), flatMapResult() helpers
- Add AsyncResult<T, E> = Promise<Result<T, E>>
- No any, no as
Enter fullscreen mode Exit fullscreen mode

Generated output:

type Result<T, E> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type AsyncResult<T, E> = Promise<Result<T, E>>;

function ok<T>(data: T): Result<T, never> {
  return { ok: true, data };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function unwrapOrThrow<T, E>(result: Result<T, E>): T {
  if (result.ok) return result.data;
  throw result.error;
}

// Error cases as a discriminated union — exhaustive by construction
type ApiError =
  | { type: "NOT_FOUND"; id: string }
  | { type: "UNAUTHORIZED" }
  | { type: "NETWORK_ERROR"; message: string };

async function fetchUser(id: string): AsyncResult<User, ApiError> {
  try {
    const res = await fetch(`/api/users/${encodeURIComponent(id)}`);
    if (res.status === 404) return err({ type: "NOT_FOUND", id });
    if (res.status === 401) return err({ type: "UNAUTHORIZED" });
    if (!res.ok) return err({ type: "NETWORK_ERROR", message: res.statusText });
    const raw: unknown = await res.json();
    return ok(parseUser(raw)); // validated, not cast
  } catch (e) {
    const message = e instanceof Error ? e.message : "unknown";
    return err({ type: "NETWORK_ERROR", message });
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a new error case to ApiError and the compiler will point out every unhandled branch in your switch statements. That's what exhaustive checking looks like.


Pattern 2: Branded Types to Prevent ID Mix-ups

// Without branded types, this compiles and silently corrupts data
function getOrder(userId: string, orderId: string) { ... }
getOrder(orderId, userId); // swapped — no error
Enter fullscreen mode Exit fullscreen mode

Prompt for Claude Code:

Following the CLAUDE.md TypeScript rules, implement Branded Types for:
UserId, OrderId, ProductId, Email

Requirements:
- Use Brand<T, B> utility type with unique symbol
- Constructor functions named parse* that return Result<BrandedType, ValidationError>
- No any, no as outside the brand-assignment boundary
Enter fullscreen mode Exit fullscreen mode

Generated output:

declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email   = Brand<string, "Email">;

type ValidationError = { field: string; message: string };

function parseUserId(raw: string): Result<UserId, ValidationError> {
  if (!raw || raw.trim().length === 0) {
    return err({ field: "userId", message: "must be non-empty" });
  }
  return ok(raw as UserId); // only brand-assignment uses `as`
}

function parseEmail(raw: string): Result<Email, ValidationError> {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!re.test(raw)) {
    return err({ field: "email", message: "invalid format" });
  }
  return ok(raw as Email);
}

// Now the compiler enforces argument order
function processOrder(userId: UserId, orderId: OrderId): void {
  console.log(`Processing order ${orderId} for user ${userId}`);
}

// processOrder(orderId, userId); → TypeScript error: Argument of type 'OrderId'
//                                   is not assignable to parameter of type 'UserId'
Enter fullscreen mode Exit fullscreen mode

The bug that was invisible at runtime is now a compile-time error.


Pattern 3: Mapped Types for API Response Transformation

Manually converting snake_case API responses to camelCase — and keeping types in sync — is tedious and error-prone.

Prompt for Claude Code:

Following the CLAUDE.md TypeScript rules, implement these type utilities:
1. SnakeToCamelCase<S extends string>  type-level string transformation
2. SnakeKeysToCamelCase<T>  recursively transform all object keys
3. DeepReadonly<T>
4. DeepPartial<T>
No any, no as  pure type-level operations only
Enter fullscreen mode Exit fullscreen mode

Generated output:

type SnakeToCamelCase<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamelCase<Tail>>}`
    : S;

type SnakeKeysToCamelCase<T> = T extends object
  ? {
      [K in keyof T as SnakeToCamelCase<K & string>]: T[K] extends object
        ? SnakeKeysToCamelCase<T[K]>
        : T[K];
    }
  : T;

type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

// Usage — the API type drives the frontend type
type ApiUser = {
  user_id: string;
  created_at: string;
  profile_image_url: string | null;
};

type FrontendUser = SnakeKeysToCamelCase<ApiUser>;
// → { userId: string; createdAt: string; profileImageUrl: string | null }

// Make it immutable for safe passing through the component tree
type ReadonlyUser = DeepReadonly<FrontendUser>;
Enter fullscreen mode Exit fullscreen mode

When the API schema changes, the derived frontend types update automatically. No manual sync required.


Putting It Together

Here's a realistic end-to-end flow:

// 1. Parse and brand external IDs at the boundary
const userIdResult = parseUserId(req.params.id);
if (!userIdResult.ok) {
  return res.status(400).json(userIdResult.error);
}
const userId = userIdResult.data; // UserId (branded)

// 2. Fetch with type-safe error handling
const fetchResult = await fetchUser(userId);
if (!fetchResult.ok) {
  switch (fetchResult.error.type) {
    case "NOT_FOUND":      return res.status(404).json({ id: fetchResult.error.id });
    case "UNAUTHORIZED":   return res.status(401).json({ error: "unauthorized" });
    case "NETWORK_ERROR":  return res.status(503).json({ error: fetchResult.error.message });
  }
}

// 3. Transform to frontend shape
const user: FrontendUser = transformKeys(fetchResult.data);
Enter fullscreen mode Exit fullscreen mode

Every error case is handled. Every ID is the right type. No any, no as, no runtime surprises.


Summary

Pattern Problem Solved
Result type + Discriminated Union Type-safe error handling without any in catch
Branded Types Prevent ID mix-ups caught at compile time
Mapped Types Auto-derive frontend types from API schema

Define the rules in CLAUDE.md once. Claude Code applies them consistently to every generation.


Code Review Pack (¥980) includes /code-review for detecting any usage and unsafe type patterns. 👉 https://prompt-works.jp

Top comments (0)