Claude Code and TypeScript: Practical Tips for Safer Speed
Use strict config, Zod, unions, generics, satisfies, and type tests to make Claude Code safer.
Claude Code can make TypeScript work feel fast, especially when you are building forms, API helpers, and tests. The catch is that it can also create fragile code quickly when type boundaries are vague. For beginners, the safest habit is simple: define the guardrails before asking Claude Code to generate the feature.
In this article, strict means “TypeScript refuses suspicious code”, domain types mean “business rules written as types”, a discriminated union means “a state model where each state has its own shape”, and runtime validation means “checking data while the program runs”.
The goal is not advanced type gymnastics. The goal is copy-pasteable TypeScript that gives Claude Code less room to invent unsafe shortcuts.
Give Claude Code a Type Map First
Start with a small map of the work: compiler rules, domain types, external inputs, state, and type tests. That map makes the generated diff easier to review.
flowchart TD
A["Requirement"] --> B["tsconfig: strict rules"]
B --> C["Domain types: Plan and Account"]
C --> D["External data: unknown then validate"]
D --> E["State: discriminated unions"]
E --> F["Type tests: expectTypeOf / tsd"]
F --> G["Claude Code implementation and review"]
Use official TypeScript references as the baseline: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, Narrowing, Generics, Utility Types, and the satisfies operator note.
For runtime validation, keep the Zod docs open as well.
Related ClaudeCodeLab reads: TypeScript Utility Types, TypeScript Generics, and Zod Validation.
Start With a Strict tsconfig
Do not only say “build this in TypeScript”. Give Claude Code the compiler contract first.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
}
Then put the rules into the prompt.
This repository uses strict TypeScript.
Do not introduce any. Treat external input as unknown and validate it with Zod.
When handling unions in switch statements, add a never exhaustiveness check.
After implementation, run npx tsc --noEmit.
noUncheckedIndexedAccess keeps undefined visible when reading arrays and records.
It may feel strict at first, but it catches missing API fields, empty lists, and incomplete CMS locale data before runtime.
Use Case 1: Model SaaS Plans as Domain Types
Domain types are business rules written in TypeScript. Plans, permissions, billing states, and publishing states should exist before the UI code.
export type Plan = "free" | "pro" | "enterprise";
export type Account = {
id: string;
email: string;
plan: Plan;
seats: number;
trialEndsAt: string | null;
};
export type CreateAccountInput = {
email: string;
plan: Exclude<Plan, "enterprise">;
seats?: number;
};
export type UpdateAccountInput = Partial<
Pick<Account, "email" | "plan" | "seats" | "trialEndsAt">
>;
Exclude removes members from a union.
Partial makes properties optional, which is useful for update APIs but dangerous for create APIs if used too broadly.
Use Case 2: Validate API Data From unknown
TypeScript types disappear at runtime.
Data from APIs, forms, cookies, local storage, CSV files, and AI output can be broken.
Accept those values as unknown, validate them, and only then use the typed result.
npm install zod
import { z } from "zod";
const AccountSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
plan: z.enum(["free", "pro", "enterprise"]),
seats: z.number().int().positive(),
trialEndsAt: z.string().datetime().nullable()
});
type Account = z.infer<typeof AccountSchema>;
export function parseAccountResponse(json: unknown): Account {
return AccountSchema.parse(json);
}
unknown means the value has not been proven yet.
Unlike any, it forces validation before property access.
Use Case 3: Keep Payment State Closed
Payments, uploads, forms, and background jobs are state machines.
Avoid status: string; use a discriminated union.
type PaymentResult =
| { status: "pending"; invoiceId: string }
| { status: "paid"; invoiceId: string; paidAt: string }
| { status: "failed"; invoiceId: string; reason: string };
export function renderPaymentMessage(result: PaymentResult): string {
switch (result.status) {
case "pending":
return `Invoice ${result.invoiceId} is waiting for payment.`;
case "paid":
return `Invoice ${result.invoiceId} was paid at ${result.paidAt}.`;
case "failed":
return `Invoice ${result.invoiceId} failed: ${result.reason}.`;
default: {
const exhaustive: never = result;
return exhaustive;
}
}
}
The never branch tells TypeScript that every valid case should already be handled.
If a later refunded state is added, the compiler forces the missing branch to be written.
Use Case 4: Generics and satisfies for Reusable Helpers
Generics make helpers reusable while preserving the specific type at each call site.
export function groupBy<T, K extends PropertyKey>(
items: readonly T[],
getKey: (item: T) => K
): Partial<Record<K, T[]>> {
const grouped: Partial<Record<K, T[]>> = {};
for (const item of items) {
const key = getKey(item);
const bucket = grouped[key] ?? [];
bucket.push(item);
grouped[key] = bucket;
}
return grouped;
}
const accounts = [
{ id: "a1", plan: "free" },
{ id: "a2", plan: "pro" },
{ id: "a3", plan: "pro" }
] as const;
const byPlan = groupBy(accounts, (account) => account.plan);
const proAccounts = byPlan.pro ?? [];
console.log(proAccounts.map((account) => account.id));
For configuration objects, prefer satisfies over a broad assertion.
type ApiRoute = {
method: "GET" | "POST" | "PATCH" | "DELETE";
path: `/${string}`;
auth: boolean;
};
const routes = {
listAccounts: { method: "GET", path: "/accounts", auth: true },
createAccount: { method: "POST", path: "/accounts", auth: true },
healthCheck: { method: "GET", path: "/health", auth: false }
} as const satisfies Record<string, ApiRoute>;
type RouteName = keyof typeof routes;
export function getRoute(name: RouteName) {
return routes[name];
}
Ask Claude Code to use satisfies for route maps, feature flags, pricing tables, and design tokens.
Add Type-Level Tests
Important exported types deserve tests.
Vitest’s expectTypeOf is practical inside a normal test suite.
npm install -D vitest tsd
import { expectTypeOf, test } from "vitest";
type CreateAccountInput = {
email: string;
plan: "free" | "pro";
seats?: number;
};
test("CreateAccountInput keeps the public API narrow", () => {
expectTypeOf<CreateAccountInput>().toMatchTypeOf<{
email: string;
plan: "free" | "pro";
seats?: number;
}>();
});
With tsd, you can also keep calls that should fail.
import { expectError, expectType } from "tsd";
import { renderPaymentMessage } from "./payment";
expectType<string>(renderPaymentMessage({
status: "pending",
invoiceId: "inv_001"
}));
expectError(renderPaymentMessage({
status: "refunded",
invoiceId: "inv_001"
}));
Practical Use Cases and Pitfalls
| Use case | What the type system owns | What Claude Code can generate |
|---|---|---|
| SaaS billing | plans, invoice states, permissions | UI branches, forms, messages |
| Admin API screens | Zod schemas, response types | fetch helpers, tables, loading states |
| Article CMS | slug, locale, publish state, hero image | MDX drafts, listings, validation fixes |
| Contact forms | input schema, submission result union | UI, submit handler, Vitest coverage |
| Pitfall | Failure | Fix |
|---|---|---|
API response typed as any | broken JSON still compiles | accept unknown, validate with Zod |
status: string | impossible states appear | use a discriminated union |
frequent as User | errors are hidden | use schemas, guards, or satisfies |
Partial<T> for create input | required fields become optional | separate create and update types |
| no type tests | exported types widen silently | add expectTypeOf or tsd |
In ClaudeCodeLab article workflows, keeping lang, slug, heroImage, and publish state as narrow types prevented invalid localized URLs from being generated.
That is the practical lesson: the narrower the boundary, the better Claude Code’s edits become.
Before asking for a large rewrite, choose one boundary and write the expected type behavior in plain English. A good request says which value is external, which state must be closed, and which exported type must not widen. That context lets Claude Code fix the real risk instead of only making TypeScript quieter.
CLAUDE.md Rules and CTA
## TypeScript rules
- Use strict TypeScript.
- Do not introduce `any`. Use `unknown` at external boundaries.
- Prefer discriminated unions for states.
- Prefer `satisfies` over broad type assertions.
- Derive API types from Zod schemas when runtime data is involved.
- Add Vitest or tsd style type checks for exported helper types.
- Run `npx tsc --noEmit` before reporting completion.
For solo projects, ClaudeCodeLab’s product catalog collects templates and checklists for Claude Code workflows.
For teams, Claude Code training and consulting can cover strict migration, Zod boundaries, type tests, and practical CLAUDE.md rules inside a real repository.
Verification Note
I tested this workflow in a small TypeScript project by replacing any API responses with unknown plus Zod, then asking Claude Code to add missing union branches and expectTypeOf tests.
The useful result was not prettier types. It was earlier detection of missing states and invalid property access before code review.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.