Skip to content
Back to Blog
Agentic AI6 min read

Claude Code for TypeScript Development: Patterns That Work

Maximize Claude Code's TypeScript capabilities — type inference, generic patterns, strict mode compliance, Zod schemas, React types, and CLAUDE.md configurations for TS projects.

Claude Code and TypeScript: A Natural Fit

TypeScript's explicit type system gives Claude Code more information to work with than dynamically typed languages. When Claude Code reads a TypeScript file, it understands the types, interfaces, and generics — and uses that information to generate code that is type-safe from the start.

This guide covers the TypeScript-specific patterns, configurations, and prompts that get the best results from Claude Code.

CLAUDE.md for TypeScript Projects

# TypeScript Project Configuration

## Compiler Settings
- strict: true (all strict checks enabled)
- noUncheckedIndexedAccess: true
- exactOptionalPropertyTypes: true
- Target: ES2022
- Module: NodeNext

## Type Conventions
- Never use \`any\` — use \`unknown\` and narrow with type guards
- Prefer interfaces for object shapes, types for unions and intersections
- Use branded types for IDs: type UserId = string & { readonly __brand: "UserId" }
- All function parameters and return types must be explicitly annotated
- Use readonly for arrays and objects that should not be mutated

## Import Conventions
- Use type-only imports: import type { User } from "./types"
- No barrel file imports — import from specific modules
- No default exports — always named exports

## Error Handling
- Use a Result type for operations that can fail
- Never throw in service functions — return Result<T, AppError>
- Use discriminated unions for error types

Type-Safe API Endpoints

Claude Code generates well-typed API handlers when given clear type requirements:

You: Create a type-safe CRUD API for a "projects" resource using Express and Zod.
Include request validation, response types, and error handling.
// types/project.ts
import { z } from "zod";

export const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(1000).optional(),
  visibility: z.enum(["public", "private"]).default("private"),
  teamId: z.string().uuid(),
});

export const UpdateProjectSchema = CreateProjectSchema.partial().omit({ teamId: true });

export const ProjectQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional(),
  visibility: z.enum(["public", "private"]).optional(),
});

export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>;

export interface Project {
  id: string;
  name: string;
  description: string | null;
  visibility: "public" | "private";
  teamId: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}
// middleware/validate.ts
import { z, ZodSchema } from "zod";
import { Request, Response, NextFunction } from "express";

export function validateBody<T extends ZodSchema>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(422).json({
        success: false,
        error: "Validation failed",
        details: result.error.flatten().fieldErrors,
      });
    }
    req.body = result.data;
    next();
  };
}

export function validateQuery<T extends ZodSchema>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.query);
    if (!result.success) {
      return res.status(400).json({
        success: false,
        error: "Invalid query parameters",
        details: result.error.flatten().fieldErrors,
      });
    }
    req.query = result.data as any;
    next();
  };
}

Advanced Type Patterns

Claude Code handles complex TypeScript patterns well when the intent is clear.

Discriminated Unions for Error Handling

// Claude Code generates clean Result types
type Result<T, E = AppError> =
  | { success: true; data: T }
  | { success: false; error: E };

type AppError =
  | { code: "NOT_FOUND"; message: string; resource: string }
  | { code: "VALIDATION"; message: string; fields: Record<string, string[]> }
  | { code: "UNAUTHORIZED"; message: string }
  | { code: "FORBIDDEN"; message: string }
  | { code: "CONFLICT"; message: string; conflictingField: string };

// Usage in services
async function getProject(id: string): Promise<Result<Project>> {
  const project = await db.project.findUnique({ where: { id } });
  if (!project) {
    return {
      success: false,
      error: { code: "NOT_FOUND", message: "Project not found", resource: "project" },
    };
  }
  return { success: true, data: project };
}

Generic Repository Pattern

// Claude Code generates clean generics when prompted
interface Repository<T, CreateInput, UpdateInput> {
  findById(id: string): Promise<T | null>;
  findMany(query: PaginationQuery): Promise<PaginatedResponse<T>>;
  create(input: CreateInput): Promise<T>;
  update(id: string, input: UpdateInput): Promise<T>;
  delete(id: string): Promise<void>;
}

class PrismaRepository<
  T,
  CreateInput,
  UpdateInput,
  Model extends keyof PrismaClient,
> implements Repository<T, CreateInput, UpdateInput> {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly model: Model,
  ) {}

  async findById(id: string): Promise<T | null> {
    return (this.prisma[this.model] as any).findUnique({ where: { id } });
  }

  async findMany(query: PaginationQuery): Promise<PaginatedResponse<T>> {
    const { page, limit } = query;
    const [data, total] = await Promise.all([
      (this.prisma[this.model] as any).findMany({
        skip: (page - 1) * limit,
        take: limit,
      }),
      (this.prisma[this.model] as any).count(),
    ]);
    return {
      data,
      pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
    };
  }

  // ... create, update, delete implementations
}

Branded Types for Type-Safe IDs

// Prevent mixing up different ID types
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type ProjectId = Brand<string, "ProjectId">;
type TeamId = Brand<string, "TeamId">;

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

// Now the compiler prevents mixing IDs:
function getProject(id: ProjectId): Promise<Project> { /* ... */ }

const userId = createUserId("abc-123");
// getProject(userId);  // TypeScript Error: UserId is not assignable to ProjectId

React + TypeScript Patterns

Claude Code generates well-typed React components:

// Generic list component with proper types
interface DataTableProps<T> {
  data: T[];
  columns: ColumnDef<T>[];
  isLoading?: boolean;
  onRowClick?: (row: T) => void;
  emptyMessage?: string;
}

function DataTable<T extends { id: string }>({
  data,
  columns,
  isLoading = false,
  onRowClick,
  emptyMessage = "No data found",
}: DataTableProps<T>) {
  if (isLoading) return <TableSkeleton columns={columns.length} />;
  if (data.length === 0) return <EmptyState message={emptyMessage} />;

  return (
    <table className="w-full">
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={String(col.accessorKey)}>{col.header}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row) => (
          <tr
            key={row.id}
            onClick={() => onRowClick?.(row)}
            className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
          >
            {columns.map((col) => (
              <td key={String(col.accessorKey)}>
                {col.cell ? col.cell(row) : String(row[col.accessorKey as keyof T])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Type Inference and Strict Mode

Claude Code respects strict TypeScript settings. When your tsconfig.json has strict mode enabled, Claude Code:

  • Never uses any (uses unknown instead)
  • Handles null and undefined explicitly
  • Returns correct types from async functions
  • Uses proper narrowing instead of type assertions
// Claude Code with strict mode — proper null handling
async function getUserEmail(userId: string): Promise<string | null> {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { email: true },
  });

  // Claude Code does NOT write: return user.email
  // It handles the null case:
  return user?.email ?? null;
}

Common Prompts for TypeScript Work

Task Prompt
Add types to JS file "Convert utils.js to TypeScript with strict types"
Type an API response "Create TypeScript types for this API response: [paste JSON]"
Zod schema from type "Generate a Zod schema that validates this TypeScript interface"
Fix type errors "Fix all TypeScript errors reported by tsc --noEmit"
Generic component "Create a generic DataTable component that works with any data shape"
Type guard "Write a type guard for the User vs AdminUser discriminated union"

Conclusion

Claude Code produces its best TypeScript when you give it a strict tsconfig.json, clear type conventions in CLAUDE.md, and explicit instructions about patterns like Result types, branded IDs, and Zod schemas. The combination of TypeScript's type system and Claude Code's reasoning creates a development experience where type errors are rare and the generated code passes tsc --noEmit on the first attempt.

Share this article
N

NYC News

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.