Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 배우는 TypeScript Generics: keyof, 제약, API 타입

Claude Code로 TypeScript Generics를 안전하게 설계하는 법을 constraints, keyof, mapped types, API 타입, tsc로 설명합니다.

Claude Code로 배우는 TypeScript Generics: keyof, 제약, API 타입

“제네릭으로 만들어줘”만으로는 부족하다

TypeScript Generics는 하나의 함수, 타입, 클래스를 여러 데이터 구조에 재사용하면서도 입력과 출력의 관계를 잃지 않게 해 주는 기능입니다. 초보자가 헷갈리는 지점은 T라는 글자 자체가 아닙니다. Claude Code에 “범용 함수로 만들어줘”라고만 말해서, 어디는 자유로워야 하고 어디는 제한되어야 하는지를 전달하지 않는 것이 문제입니다. 그러면 겉보기에는 재사용 가능한 코드가 나오지만 내부는 any, unknown, 너무 넓은 Record<string, unknown>에 기대는 경우가 많습니다.

실무에서 generics는 API 데이터, 폼, 계정, 결제, 분석 이벤트, 상품 CTA와 자주 연결됩니다. 이 영역에서 타입이 넓어지면 단순히 자동완성이 약해지는 문제가 아니라, 상담 폼이나 결제 흐름에 잘못된 값이 들어갈 수 있습니다. Masa는 Claude Code에 구현을 맡길 때 “성공해야 하는 예”와 “컴파일에서 실패해야 하는 예”를 같이 요청합니다.

전체 흐름은 이렇게 보면 됩니다.

입력값 -> T로 타입을 캡처 -> keyof T로 가능한 키 제한 -> mapped types로 구조 변환 -> tsc로 계약 검증

이 글의 문법은 TypeScript 공식 문서의 Generics, keyof Type Operator, Mapped Types, Conditional Types을 확인해 사용했습니다. Claude Code에서 TypeScript 작업 흐름을 더 넓게 잡고 싶다면 TypeScript 실전 팁Utility Types 활용을 이어서 보면 좋습니다.

코드 전에 리뷰 기준을 정한다

Generics는 컴파일 시간의 도구입니다. T는 런타임 변수라기보다, 함수에 어떤 타입이 들어왔고 어떤 타입이 나가야 하는지를 컴파일러가 기억하게 하는 타입 인자입니다. 여기서 extends는 상속보다 “이 모양을 만족하는 타입만 허용한다”에 가깝습니다. keyof TT가 가진 속성 이름의 집합을 만들고, mapped types는 그 속성들을 순회해 새 타입을 만듭니다.

Claude Code에 코드를 맡기기 전에 아래 표처럼 계약을 먼저 줍니다.

질문Claude Code에 줄 정보리뷰할 부분
T는 무엇인가도메인 객체, DTO, 폼 모델결과가 원래 타입을 잃지 않는가
무엇을 제한할 것인가K extends keyof T, E extends ApiError, T extends object잘못된 호출이 컴파일에서 막히는가
어떻게 검증할 것인가@ts-expect-error, Expect, 엄격한 tsc 명령실패 예제가 정말 실패하는가

이 기준이 없으면 Claude Code가 마지막에 캐스트를 붙여 “통과만 하는” 코드를 만들 수 있습니다. 캐스트는 TypeScript가 추론하지 못하는 변환을 설명할 때는 괜찮지만, 넓은 타입 설계를 숨기는 용도로 쓰이면 위험합니다.

예제1: keyof로 안전한 중복 제거

첫 번째 유스케이스는 API 목록, CSV 가져오기, 관리자 테이블에서 자주 쓰는 uniqueBy입니다. key를 단순히 string으로 두면 존재하지 않는 필드도 컴파일됩니다. K extends keyof T를 사용하면 선택 가능한 키가 실제 객체의 속성으로 제한됩니다.

type User = {
  id: string;
  email: string;
  role: "admin" | "editor";
  score: number;
};

function uniqueBy<T>(items: readonly T[]): T[];
function uniqueBy<T, K extends keyof T>(items: readonly T[], key: K): T[];
function uniqueBy<T, K extends keyof T>(items: readonly T[], key?: K): T[] {
  const seen = new Set<unknown>();
  const output: T[] = [];

  for (const item of items) {
    const value = key === undefined ? item : item[key];
    if (seen.has(value)) continue;
    seen.add(value);
    output.push(item);
  }

  return output;
}

const users: User[] = [
  { id: "u_1", email: "masa@example.com", role: "admin", score: 92 },
  { id: "u_2", email: "editor@example.com", role: "editor", score: 88 },
  { id: "u_1", email: "masa+copy@example.com", role: "admin", score: 70 },
];

const byId = uniqueBy(users, "id");
const byRole = uniqueBy(users, "role");

// @ts-expect-error "missing" is not a key of User.
uniqueBy(users, "missing");

console.log(byId.map((user) => user.id));
console.log(byRole.map((user) => user.role));

프롬프트에는 “overload를 쓰고, key는 반드시 keyof T로 제한하며, 존재하지 않는 키는 @ts-expect-error로 검증해 달라”고 적습니다. 그렇지 않으면 key: stringitem[key as keyof T] 같은 캐스트 중심 코드가 나올 수 있습니다.

예제2: API 응답을 optional로 뭉개지 않는다

두 번째 유스케이스는 API 응답입니다. 많은 코드가 data?: T, error?: ApiError를 한 타입에 넣습니다. 편해 보이지만 호출하는 쪽은 매번 성공인데 data가 없는지, 실패인데 error가 없는지 확인해야 합니다. 판별 가능한 union을 쓰면 성공은 data, 실패는 error를 갖고, ok로 타입이 좁혀집니다.

type ApiError = {
  code: string;
  message: string;
  retryable: boolean;
};

type ApiResult<T, E extends ApiError = ApiError> =
  | { ok: true; status: number; data: T; error?: never }
  | { ok: false; status: number; error: E; data?: never };

type UserDto = {
  id: string;
  name: string;
  plan: "free" | "pro";
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function parseUserResponse(json: unknown): ApiResult<UserDto> {
  if (
    isRecord(json) &&
    typeof json.id === "string" &&
    typeof json.name === "string" &&
    (json.plan === "free" || json.plan === "pro")
  ) {
    return {
      ok: true,
      status: 200,
      data: { id: json.id, name: json.name, plan: json.plan },
    };
  }

  return {
    ok: false,
    status: 422,
    error: {
      code: "INVALID_USER_RESPONSE",
      message: "User response does not match the expected shape.",
      retryable: false,
    },
  };
}

function unwrap<T, E extends ApiError>(result: ApiResult<T, E>): T {
  if (result.ok) {
    return result.data;
  }

  throw new Error(`${result.error.code}: ${result.error.message}`);
}

const parsed = parseUserResponse({ id: "u_1", name: "Masa", plan: "pro" });
const user = unwrap(parsed);
console.log(user.name.toUpperCase());

이 패턴은 런타임 검증과 타입 계약을 한 번에 설명할 수 있어서 Claude Code와 잘 맞습니다. 백엔드 설계는 Claude Code API 개발, 검증 흐름은 API 테스트 자동화와 같이 보면 좋습니다.

예제3: mapped types로 폼 상태 만들기

세 번째 유스케이스는 폼 상태입니다. 원래 업무 타입에서 각 필드마다 value, dirty, errors를 가진 상태 타입을 만듭니다. mapped types를 쓰면 필드 이름을 두 번 쓰지 않아도 되고, email은 문자열, seats는 숫자, newsletter는 불리언이라는 관계가 유지됩니다.

type FieldState<T> = {
  value: T;
  dirty: boolean;
  errors: string[];
};

type FormState<T extends object> = {
  [K in keyof T]: FieldState<T[K]>;
};

function createFormState<T extends object>(initial: T): FormState<T> {
  const entries = Object.entries(initial).map(([key, value]) => [
    key,
    { value, dirty: false, errors: [] },
  ]);

  return Object.fromEntries(entries) as FormState<T>;
}

function setField<T extends object, K extends keyof T>(
  state: FormState<T>,
  key: K,
  value: T[K],
): FormState<T> {
  return {
    ...state,
    [key]: { value, dirty: true, errors: [] },
  } as FormState<T>;
}

type SignupForm = {
  email: string;
  seats: number;
  newsletter: boolean;
};

const form = createFormState<SignupForm>({
  email: "team@example.com",
  seats: 2,
  newsletter: true,
});

const updated = setField(form, "seats", 3);

// @ts-expect-error seats must be a number.
setField(form, "seats", "three");

console.log(updated.seats.value);

Object.fromEntries 뒤의 캐스트는 눈여겨봐야 합니다. 외부 입력을 믿겠다는 뜻이 아니라, 같은 키를 유지하는 변환을 TypeScript가 정확히 추론하지 못해 보완하는 것입니다. Claude Code에는 캐스트를 추가할 때마다 왜 안전한지 설명하게 하세요.

tsc와 타입 테스트로 확인하기

Generics 예제는 읽는 것보다 검증이 중요합니다. 샘플을examples/generics.ts에 넣고 엄격 모드로 확인합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "lib": ["ES2022", "DOM"]
  },
  "include": ["examples/**/*.ts"]
}
npm install --save-dev typescript
npx tsc --noEmit --strict --lib ES2022,DOM examples/generics.ts

타입만 검사하려면 컴파일 타임 assertion을 추가합니다. 실행 시 동작은 없지만, 기대한 타입이 아니면 빌드가 실패합니다.

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2)
    ? true
    : false;

type Expect<T extends true> = T;

type PickReadonly<T, K extends keyof T> = {
  readonly [P in K]: T[P];
};

type Account = {
  id: string;
  email: string;
  seats: number;
};

type PublicAccount = PickReadonly<Account, "id" | "email">;

type PublicAccountCheck = Expect<
  Equal<PublicAccount, { readonly id: string; readonly email: string }>
>;

const leaked: PublicAccount = {
  id: "a_1",
  email: "team@example.com",
  // @ts-expect-error seats is intentionally not part of PublicAccount.
  seats: 10,
};

console.log("Type checks are compile-time only.");

Claude Code 타입 리뷰 템플릿

생성 후에는 리뷰도 Claude Code에 맡기되, 질문을 좁혀야 합니다.

템플릿1: 제네릭 함수 리뷰
이 TypeScript 함수를 리뷰해 주세요.
목표: 입력 배열을 선택한 key로 중복 제거한다.
조건: key는 K extends keyof T여야 한다. any 금지. 없는 key는 @ts-expect-error로 검증.
출력: 문제점, 수정 코드, 검증용 tsc 명령.
템플릿2: API 응답 타입 리뷰
이 API 응답 타입을 리뷰해 주세요.
목표: 성공 시 data만, 실패 시 error만 가진다.
조건: data?: T 같은 모호한 optional 설계를 피한다. ok로 타입이 좁혀지는지 확인.
출력: 안전한 호출 예, 실패 예, 추가 타입 테스트.
템플릿3: mapped types 리뷰
이 mapped type을 리뷰해 주세요.
목표: 기존 폼 모델에서 필드 상태 타입을 만든다.
조건: keyof, T[K], readonly, optional 속성, 필요한 캐스트를 설명한다.
출력: 타입 흐름, 취약한 케이스, 최소 수정안.
템플릿4: PR 전 타입 감사
이 diff의 generics, conditional types, mapped types를 감사해 주세요.
확인: any, 너무 넓은 Record, 불필요한 타입 인자, @ts-expect-error 부족, 런타임 검증 부족.
출력: blocker, 작은 개선, 추가 테스트를 우선순위순으로 정리.

자주 나오는 함정

함정깨지는 부분안전한 습관
any로 범용화반환 타입 정보가 사라짐T로 입력과 출력 관계를 보존
key를string으로 둠없는 필드도 컴파일됨K extends keyof T 사용
Record<string, unknown> 남용구체적 속성이 사라짐dictionary가 아니면 object 고려
API 필드를 optional로만 구성dataerror를 신뢰하기 어려움판별 가능한 union 사용
캐스트 이유를 쓰지 않음리뷰어가 안전성을 판단 못 함캐스트 전 불변 조건 설명

특히 T extends objectT extends Record<string, unknown>의 차이를 구분해야 합니다. 폼 모델은 보통 객체이면 충분하고, 임의 문자열 키로 접근하는 dictionary helper라면 Record가 더 맞습니다.

CTA: 타입 안전성을 수익 경로와 연결하기

Generics는 문법 지식으로 끝나지 않습니다. 리드 폼, 결제, API payload, 상품 템플릿, analytics event의 타입이 약하면 독자가 고객으로 이동하는 경로가 깨질 수 있습니다. 개인 학습은 무료 Claude Code cheatsheet로 시작하고, 반복되는 프롬프트와 설정 자료가 필요하면 ClaudeCodeLab products를 확인하세요. 팀에서CLAUDE.md, 타입 리뷰 규칙, CI gate, 출시 기준까지 정리해야 한다면 Claude Code training으로 연결하는 것이 자연스럽습니다.

자기 저장소에 적용할 때는 계정, 결제, 폼, API 응답, 이벤트 추적 타입부터 보세요. Claude Code에는 “컴파일되나요?”뿐 아니라 “이 타입 실수가 전환 경로를 깨뜨릴 수 있나요?”까지 묻는 것이 좋습니다.

실제로 확인한 결과

이 흐름을 실제로 시도해 보니, Masa의 환경에서는 구현 프롬프트와 타입 리뷰 프롬프트를 분리했을 때 결과가 더 안정적이었습니다. 첫 번째 요청에서는 helper를 만들고, 두 번째 요청에서는any, 빠진keyof, optional이 과한 API 결과, 부족한@ts-expect-error를 점검합니다. 특히uniqueBy와 폼 상태 예제는tsc --noEmit --strict로 올바른 호출은 통과하고 일부러 틀린 호출은 실패한다는 것을 동시에 확인할 수 있어 유용했습니다.

#Claude Code #TypeScript #generics #type safety #design patterns
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.