Claude Code와 TypeScript: 빠르면서 안전하게 개발하는 실전 팁
strict, Zod, Union, 제네릭, satisfies, 타입 테스트로 Claude Code TypeScript 품질을 높입니다.
Claude Code를 쓰면 TypeScript 구현 속도는 확실히 빨라집니다. 하지만 타입 경계가 흐리면, 깨지기 쉬운 코드도 같은 속도로 늘어납니다. 초보자에게 가장 중요한 습관은 기능 요청 전에 타입 규칙을 먼저 주는 것입니다.
여기서 strict는 의심스러운 타입을 놓치지 않는 설정입니다.
도메인 타입은 업무 규칙을 코드로 옮긴 타입이고, 판별 가능한 Union은 상태마다 필요한 값을 나누는 타입입니다.
런타임 검증은 프로그램 실행 중에 값의 모양을 확인하는 처리입니다.
Claude Code에 타입 지도를 먼저 주기
긴 프롬프트보다 작은 타입 지도가 효과적입니다. 컴파일러 규칙, 도메인 타입, 외부 입력, 상태, 타입 테스트를 분리하면 생성된 diff를 리뷰하기 쉬워집니다.
flowchart TD
A["요구사항"] --> B["tsconfig: strict 규칙"]
B --> C["도메인 타입: Plan과 Account"]
C --> D["외부 데이터: unknown 후 검증"]
D --> E["상태: 판별 가능한 Union"]
E --> F["타입 테스트: expectTypeOf / tsd"]
F --> G["Claude Code 구현과 리뷰"]
공식 자료는 strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, Narrowing, Generics, Utility Types, satisfies 설명을 기준으로 보세요.
런타임 검증은 Zod 문서도 함께 확인합니다.
관련 글은 TypeScript Utility Types, TypeScript Generics, Zod Validation입니다.
strict tsconfig부터 고정하기
Claude Code에게 “TypeScript로 만들어줘”라고만 말하지 마세요. 먼저 컴파일러 규칙을 고정합니다.
{
"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"]
}
프롬프트에는 제약을 같이 넣습니다.
이 저장소는 strict TypeScript를 사용합니다.
any를 추가하지 마세요. 외부 입력은 unknown으로 받고 Zod로 검증하세요.
switch에서 Union을 처리할 때 never로 exhaustiveness check를 넣으세요.
구현 후 npx tsc --noEmit을 실행하세요.
noUncheckedIndexedAccess는 배열이나 객체를 읽을 때 undefined 가능성을 남깁니다.
번거롭지만 API 필드 누락, 빈 목록, CMS 번역 누락을 빨리 찾습니다.
유스케이스1: SaaS 요금제를 도메인 타입으로 만들기
도메인 타입은 비즈니스 규칙을 TypeScript로 적은 것입니다. 요금제, 권한, 청구 상태, 게시 상태는 UI보다 먼저 모델링합니다.
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는 Union에서 특정 멤버를 빼고, Partial은 속성을 선택 사항으로 만듭니다.
업데이트 API에는 편하지만 생성 입력에 그대로 쓰면 필수 값까지 선택 사항이 됩니다.
유스케이스2: API 응답을 unknown에서 검증하기
TypeScript 타입은 런타임에 사라집니다.
API, 폼, Cookie, localStorage, CSV, AI 출력은 모두 깨질 수 있으므로 경계에서는 unknown으로 받고 검증 후 사용합니다.
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은 아직 무엇인지 증명하지 않은 값입니다.
any와 달리 검증 없이 속성을 읽을 수 없습니다.
유스케이스3: 결제 상태를 Union으로 닫기
결제, 업로드, 폼 제출, 백그라운드 작업은 상태를 가집니다.
status: string은 존재하지 않는 상태까지 허용합니다.
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;
}
}
}
never는 모든 유효한 경우가 처리되었음을 확인하는 장치입니다.
나중에 refunded 상태를 추가하고 분기를 빼먹으면 컴파일러가 알려줍니다.
유스케이스4: 제네릭과 satisfies 활용하기
제네릭은 재사용 가능한 함수를 만들면서 호출한 쪽의 구체적인 타입을 보존합니다.
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));
설정 객체에는 넓은 타입 단언보다 satisfies를 사용합니다.
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];
}
타입 레벨 테스트 추가하기
중요한 공개 타입은 테스트합니다.
Vitest의 expectTypeOf를 일반 테스트 안에 둘 수 있습니다.
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;
}>();
});
실제 유스케이스와 함정 (Use case / Pitfall checklist)
| 유스케이스 | 타입 시스템이 고정할 것 | Claude Code에 맡길 것 |
|---|---|---|
| SaaS 과금 | 요금제, 청구 상태, 권한 | UI 분기, 폼, 메시지 |
| 관리자 API 화면 | Zod 스키마, 응답 타입 | fetch 함수, 테이블, 로딩 처리 |
| 글 CMS | slug, 언어, 공개 상태, 대표 이미지 | MDX 초안, 목록, 검증 수정 |
| 문의 폼 | 입력 스키마, 제출 결과 Union | UI, 제출 처리, Vitest |
| 함정 | 결과 | 대책 |
|---|---|---|
API 응답을 any로 받음 | 깨진 JSON도 컴파일됨 | unknown과 Zod 사용 |
status: string | 불가능한 상태가 들어옴 | 판별 가능한 Union 사용 |
as User 남발 | 오류를 숨김 | 스키마, 타입 가드, satisfies 사용 |
생성 입력에 Partial<T> 사용 | 필수 항목도 선택 사항이 됨 | create와 update 타입 분리 |
| 타입 테스트 없음 | 공개 타입이 조용히 넓어짐 | expectTypeOf 또는 tsd 추가 |
ClaudeCodeLab 기사 생성에서도 lang: string이 너무 넓어 잘못된 locale URL이 생긴 적이 있습니다.
locale을 닫힌 Union으로 바꾸자 Claude Code의 수정 제안도 훨씬 안정됐습니다.
실제 프로젝트에 적용할 때는 세 가지 Use case부터 보는 것이 안전합니다. 첫째는 외부 입력 경계입니다. 폼, Webhook, CSV, 외부 API, AI 출력은 unknown으로 받고 schema를 통과한 뒤에만 사용합니다. 둘째는 상태 경계입니다. 결제, 업로드, 승인, 게시 상태는 문자열이 아니라 판별 가능한 Union으로 닫습니다. 셋째는 공개 타입 경계입니다. 컴포넌트 props, SDK 응답, CMS frontmatter처럼 여러 파일이 함께 쓰는 타입은 타입 테스트로 넓어지지 않게 막습니다.
가장 흔한 Pitfall은 Claude Code에게 “타입 에러만 없애줘”라고 말하는 것입니다. 그러면 as User가 늘거나, 닫힌 Union이 다시 string으로 풀릴 수 있습니다. 요청에는 “공개 타입을 넓히지 말 것”, “외부 입력은 검증 후 사용”, “새 상태를 추가하면 switch 분기도 추가”를 같이 적어야 합니다. 이 정도만 고정해도 diff가 작아지고 리뷰가 쉬워집니다.
CLAUDE.md 규칙과 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.
개인 프로젝트에서는 제품 목록의 Claude Code 템플릿과 체크리스트를 활용할 수 있습니다.
팀에서 strict 전환, Zod 경계, 타입 테스트, CLAUDE.md 규칙을 실제 저장소에 맞추고 싶다면 Claude Code 교육과 상담을 확인하세요.
검증 결과
작은 TypeScript 프로젝트에서 API 응답을 any에서 unknown과 Zod로 바꾸고, Claude Code에 Union 분기와 expectTypeOf 테스트를 추가하게 했습니다.
그 결과 코드 리뷰 전에 누락된 상태와 존재하지 않는 속성 접근을 발견할 수 있었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.