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

TypeScript 유틸리티 타입 입문: Claude Code로 실무 타입 설계하기

실행 가능한 예제로 Pick/Omit/Partial/Record/ReturnType/Awaited를 익히고 Claude Code 타입 설계를 안전하게 만듭니다.

TypeScript 유틸리티 타입 입문: Claude Code로 실무 타입 설계하기

TypeScript 유틸리티 타입은 기존 타입을 재료로 삼아 다른 목적의 타입을 만드는 도구입니다. User 타입을 손으로 복사해서 공개 화면용, 폼 입력용, API 업데이트용으로 따로 관리하면 필드가 바뀔 때 쉽게 어긋납니다. Claude Code가 타입 리팩터링을 도와줄 수 있지만, 사람이 각 도구의 의도를 알아야 생성 결과를 제대로 검토할 수 있습니다.

이 글에서는 Pick, Omit, Partial, Required, Readonly, Record, ReturnType, Awaited를 초보자도 이해할 수 있게 정리합니다. 그다음 복사해서 실행할 수 있는 예제로 Claude Code에 어떤 식으로 요청하면 실무 타입 설계가 되는지 확인합니다. 정확한 정의는 TypeScript Handbook Utility Types를 기준으로 보고, 타입 문제를 빨리 드러내려면 TSConfig strict도 함께 확인하세요.

관련 글로는 TypeScript 실전 팁TypeScript 제네릭 가이드를 같이 보면 흐름이 이어집니다.

먼저 쉽게 이해하기

유틸리티 타입은 타입을 안전하게 “복사하고 모양을 바꾸는” 방법입니다. 스프레드시트를 복사한 뒤 필요한 열만 남기거나, 일부 열을 선택 입력으로 바꾸는 것과 비슷합니다. 다만 TypeScript에서는 이 변환이 코드 실행 전에 검사됩니다.

Pick<User, "id" | "name">User에서 idname만 고릅니다. Omit<User, "passwordHash">는 대부분 유지하되 passwordHash만 제외합니다. 두 타입은 비슷하지만 읽는 방향이 다릅니다. 대상 타입이 작으면 Pick, 원본과 거의 같고 위험한 필드만 빼면 Omit이 더 읽기 쉽습니다.

Partial<User>는 모든 속성을 선택으로 만듭니다. 초안, PATCH 입력, 작성 중인 폼에는 좋지만, 새 사용자 생성에 꼭 필요한 email까지 선택으로 만드는 실수가 생길 수 있습니다. Required<User>는 반대로 선택 속성을 필수로 바꿉니다. Readonly<User>는 재할당을 막아 설정이나 마스터 데이터에 유용합니다.

Record<Keys, Type>은 정해진 키를 가진 딕셔너리를 만듭니다. ReturnType<typeof fn>은 함수 반환 타입을 꺼냅니다. Awaited<Promise<T>>await 후 얻게 되는 값의 타입을 꺼냅니다. 둘을 함께 쓰면 API 함수에서 화면이 사용할 결과 타입을 자동으로 가져올 수 있습니다.

flowchart LR
  A["Source type: User"] --> B["Pick: public view"]
  A --> C["Omit: remove secrets"]
  A --> D["Partial: update input"]
  A --> E["Required: validated input"]
  A --> F["Readonly: fixed settings"]
  G["Function"] --> H["ReturnType"]
  I["Promise"] --> J["Awaited"]

빠른 비교표

타입하는 일실무 사용처주의점
Pick<T, K>특정 키만 선택목록, 공개 프로필, 카드선택하지 않은 키는 사용할 수 없음
Omit<T, K>특정 키만 제외생성 입력, 공개 출력, 로그런타임 값은 삭제하지 않음
Partial<T>모든 키를 선택으로 변경초안, PATCH, 폼 중간 상태얕게만 적용됨
Required<T>모든 키를 필수로 변경저장 직전 검증 완료 데이터너무 많은 필드를 요구할 수 있음
Readonly<T>재할당 방지설정, 권한, 상수중첩 객체는 별도 관리 필요
Record<K, T>고정 키 딕셔너리 생성역할 권한, 라벨, 가격표Record<string, T>는 대개 너무 넓음
ReturnType<T>함수 반환 타입 추출API와 UI 타입 동기화typeof functionName을 사용
Awaited<T>Promise 해석 후 타입 추출async 함수 결과런타임 await가 아님

이 표를 Claude Code에 붙이고 타입 선택이 목적에 맞는지 검토해 달라고 하면, 단순히 “타입 정리해줘”보다 훨씬 안정적입니다. strict: true 프로젝트에서는 애매한 타입이 더 빨리 드러나므로 이 습관이 특히 중요합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  }
}

사례1: User에서 화면과 폼 타입 파생하기

관리 화면에서는 같은 엔티티의 여러 버전이 필요합니다. DB 타입, 공개 표시 타입, 폼 입력 타입을 손으로 따로 쓰면 언젠가 필드가 어긋납니다. 원본 타입 하나를 두고 나머지는 유틸리티 타입으로 파생하는 방식이 더 안전합니다.

type UserRole = "admin" | "editor" | "viewer";

interface User {
  id: string;
  name: string;
  email: string;
  role: UserRole;
  bio: string;
  passwordHash: string;
  createdAt: Date;
  updatedAt: Date;
}

type PublicUser = Pick<User, "id" | "name" | "role" | "bio">;
type UserDraft = Partial<Omit<User, "id" | "passwordHash" | "createdAt" | "updatedAt">>;

type CreateUserInput =
  Required<Pick<User, "name" | "email" | "role">> &
  Partial<Pick<User, "bio">>;

function buildCreatePayload(input: CreateUserInput): Omit<User, "id" | "createdAt" | "updatedAt"> {
  return {
    name: input.name,
    email: input.email,
    role: input.role,
    bio: input.bio ?? "",
    passwordHash: "hashed-by-server",
  };
}

const publicUser: PublicUser = {
  id: "u_001",
  name: "Masa",
  role: "admin",
  bio: "Claude Code workflow designer",
};

const draft: UserDraft = {
  name: "Draft user",
  bio: "Saved before email is confirmed",
};

console.log(publicUser);
console.log(buildCreatePayload({ name: "Aki", email: "aki@example.com", role: "editor" }));
console.log(draft);

Claude Code에는 무엇을 남기고, 무엇을 지우고, 언제 필수인지 분리해서 요청하세요.

Create public display, form draft, and create-API types from the User type.
Do not expose passwordHash.
For creation, require only name, email, and role. Keep bio optional.
Use Pick/Omit/Partial/Required and briefly explain why each utility type is used.

실무에서는 Zod 같은 런타임 검증과 함께 써야 합니다. 유틸리티 타입은 실행 전에 모양을 확인하는 도구이지, 사용자가 제출한 값 자체를 검증하지는 않습니다.

사례2: Record로 플랜 기능 고정하기

가격 플랜, 역할 권한, 상태표처럼 키가 정해진 데이터는 Record와 잘 맞습니다. team 플랜을 빠뜨리거나 prioritySupport 철자를 틀리는 문제를 컴파일 시점에 찾기 쉽습니다.

type Plan = "free" | "pro" | "team";
type Feature = "exportPdf" | "inviteMember" | "prioritySupport";

const featureMatrix: Readonly<Record<Plan, Readonly<Record<Feature, boolean>>>> = {
  free: {
    exportPdf: false,
    inviteMember: false,
    prioritySupport: false,
  },
  pro: {
    exportPdf: true,
    inviteMember: false,
    prioritySupport: false,
  },
  team: {
    exportPdf: true,
    inviteMember: true,
    prioritySupport: true,
  },
};

function canUse(plan: Plan, feature: Feature): boolean {
  return featureMatrix[plan][feature];
}

console.log(canUse("pro", "exportPdf"));
console.log(canUse("free", "prioritySupport"));

Readonly는 이 표가 중간에 바뀌면 안 된다는 의도를 타입에 남깁니다. 기본적으로 얕게 적용되므로 예제처럼 내부 Record에도 Readonly를 겹쳤습니다. 더 깊은 데이터라면 as const나 프로젝트 전용 deep readonly 타입을 검토하세요.

사례3: ReturnType과 Awaited로 API 결과 재사용하기

API 클라이언트 타입과 UI 타입을 따로 쓰면 응답 변경에 약합니다. ReturnTypeAwaited를 조합하면 async 함수 결과에서 바로 타입을 뽑을 수 있습니다.

async function fetchInvoice(invoiceId: string) {
  return {
    id: invoiceId,
    status: "paid" as const,
    amount: 48000,
    currency: "JPY" as const,
    paidAt: new Date("2026-06-02T10:00:00+09:00"),
  };
}

type Invoice = Awaited<ReturnType<typeof fetchInvoice>>;
type InvoiceSummary = Pick<Invoice, "id" | "status" | "amount" | "currency">;

function formatInvoice(invoice: InvoiceSummary): string {
  return `${invoice.id}: ${invoice.amount.toLocaleString()} ${invoice.currency} (${invoice.status})`;
}

async function main() {
  const invoice = await fetchInvoice("inv_20260602");
  console.log(formatInvoice(invoice));
}

main();

API 함수가 신뢰할 수 있는 경계라면 Claude Code에 중복 응답 타입을 손으로 만들지 말고 함수에서 도출하라고 지시하세요. 외부 API나 사용자 입력이 경계라면 런타임 검증과 에러 처리를 함께 추가해야 합니다.

사례4: PATCH 입력에서 Partial을 올바른 깊이에 쓰기

Partial<T>는 얕습니다. 중첩 객체의 필드까지 자동으로 선택이 되지는 않으므로 초보자가 자주 막히는 지점입니다.

interface Profile {
  id: string;
  displayName: string;
  settings: {
    emailNotification: boolean;
    smsNotification: boolean;
  };
}

type ProfilePatch =
  Omit<Partial<Profile>, "settings"> & {
    settings?: Partial<Profile["settings"]>;
  };

function patchProfile(current: Profile, patch: ProfilePatch): Profile {
  return {
    ...current,
    ...patch,
    settings: {
      ...current.settings,
      ...patch.settings,
    },
  };
}

const profile: Profile = {
  id: "p_001",
  displayName: "Masa",
  settings: {
    emailNotification: true,
    smsNotification: false,
  },
};

console.log(patchProfile(profile, { settings: { smsNotification: true } }));

ProfilePatch = Partial<Profile>만 쓰면 settings를 업데이트할 때 전체 settings 객체를 요구합니다. 팀 코드에서는 영리한 범용 deep partial보다 이렇게 구체적인 타입이 더 읽기 쉬운 경우가 많습니다.

피해야 할 실패 사례

Omit은 타입에서 키를 없앨 뿐, 런타임 객체에서 속성을 삭제하지 않습니다. 로그나 공개 API 응답을 반환한다면 민감한 값을 실제로 제거해야 합니다.

interface Account {
  id: string;
  email: string;
  passwordHash: string;
}

type SafeAccount = Omit<Account, "passwordHash">;

function toSafeAccount(account: Account): SafeAccount {
  const { passwordHash, ...safeAccount } = account;
  return safeAccount;
}

console.log(toSafeAccount({
  id: "a_001",
  email: "masa@example.com",
  passwordHash: "secret",
}));

Record<string, T>는 비즈니스 규칙을 표현하기엔 대개 너무 넓습니다. 허용 키가 정해져 있다면 type Plan = "free" | "pro" | "team" 같은 유니언을 쓰세요.

Required<T>를 너무 일찍 폼에 적용하면 사용자에게 불필요하게 많은 입력을 요구하게 됩니다. 검증이 끝난 저장 직전 데이터에 쓰는 편이 안전합니다.

Awaited<T>는 Promise가 풀린 뒤의 타입을 설명할 뿐입니다. 런타임 대기는 여전히 await.then()이 필요합니다. 이 차이를 흐리면 Claude Code가 타입만 정리하고 loading/error 처리를 빠뜨릴 수 있습니다.

Claude Code 리뷰 프롬프트

구현 후에는 막연한 정리 요청보다 위험 중심 리뷰를 요청하세요.

Review this TypeScript type design.
1. Are Pick/Omit/Partial/Required/Readonly/Record matched to their use cases?
2. Are secrets removed at runtime, not only with Omit?
3. Does Partial make create inputs too loose?
4. Do ReturnType and Awaited reduce duplicated API types?
5. Are any vague any or broad string types left under strict settings?

이 질문은 타입의 멋짐보다 결함 예방에 초점을 둡니다. Masa도 작은 관리 화면에서 Partial을 너무 넓게 써서 빈 email이 저장 직전까지 허용되는 문제를 겪었습니다. 이후 “초안”, “생성”, “저장됨”을 별도 타입으로 나누니 Claude Code의 수정 제안도 훨씬 검토하기 쉬웠습니다.

정리

TypeScript 유틸리티 타입은 어려운 타입 장난이 아닙니다. 공개 데이터, 초안, 검증 완료 입력, 고정 설정, async API 결과의 차이를 반복 작성 없이 표현하는 실무 도구입니다. PickOmit으로 필드를 제어하고, PartialRequired로 흐름 단계를 나누고, ReadonlyRecord로 설정 누락을 줄이고, ReturnTypeAwaited로 응답 타입 중복을 줄이세요.

ClaudeCodeLab은 Claude Code를 TypeScript 아키텍처, 콘텐츠 CMS, 내부 도구, 수익화 퍼널에 적용하는 일을 돕습니다. 타입은 있는데도 사고가 줄지 않는다면 Claude Code 교육 및 컨설팅에서 상담할 수 있습니다.

이 글의 예제는 strict TypeScript 관점으로 확인했습니다. 가장 큰 실전 효과는 ReturnTypeAwaited에서 나왔습니다. API 응답이 바뀔 때 화면 쪽 수정 지점이 더 빨리 드러납니다. 다만 Omit은 런타임의 비밀 값을 지우지 않으므로 공개 응답 함수에는 명시적인 객체 분리 처리가 반드시 필요합니다.

#Claude Code #TypeScript #utility types #type safety #코드 품질
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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