유틸리티 타입 활용하기
레고 블록을 생각해봅시다. 기본 블록을 조립해서 다양한 모양을 만들 수 있습니다. 그런데 레고 세트에는 특수 변환 블록도 들어있습니다. 기존 블록을 붙이면 모양이 바뀌거나 일부가 생략됩니다. 빨간 블록을 변환 블록에 붙이면 파란 블록이 되거나, 블록의 일부만 남기거나, 선택적으로 만들 수 있습니다.
TypeScript의 유틸리티 타입이 바로 이 변환 블록입니다. 기존 타입을 변형해서 새로운 타입을 만드는 도구들입니다. 모두 제네릭으로 구현되어 있습니다.
기준이 되는 타입
예제 전체에서 쓸 기준 타입을 먼저 정의합니다.
// 새 파일: utility-types-base.tsinterface User { id: number; name: string; email: string; age: number; role: "admin" | "user" | "guest";}
이 User 타입을 다양하게 변형해봅시다.
Partial — 모든 속성을 선택적으로
Partial<T>는 T의 모든 속성을 선택적(?)으로 만듭니다. 업데이트할 때 일부 속성만 바꾸고 싶을 때 유용합니다.
// 새 파일: partial-example.tsinterface User { id: number; name: string; email: string; age: number; role: "admin" | "user" | "guest";}// Partial<User>의 타입:// {// id?: number;// name?: string;// email?: string;// age?: number;// role?: "admin" | "user" | "guest";// }function updateUser(userId: number, updates: Partial<User>): void { // updates에는 User의 어떤 속성이든 일부만 들어올 수 있습니다 console.log(`User ${userId} 업데이트:`, updates);}updateUser(1, { name: "Alice Updated" });// name만 업데이트updateUser(2, { age: 31, email: "[email protected]" });// age와 email만 업데이트updateUser(3, {});// 아무것도 업데이트하지 않는 것도 됩니다
내부 원리. TypeScript 표준 라이브러리에서 Partial은 다음과 같이 정의되어 있습니다.
// TypeScript 내장 타입 (직접 쓰지 않아도 됩니다 — 참고용)type Partial<T> = { [P in keyof T]?: T[P];};
[P in keyof T]는 T의 모든 키를 순회하고, ?를 붙여 선택적으로 만듭니다. 값 타입은 T[P]로 원래 타입을 유지합니다. 이 문법(맵드 타입)의 상세한 내용은 "실전 TypeScript"에서 다룹니다. 지금은 "모든 속성을 선택적으로 바꿔준다"고 이해하면 충분합니다.
Required — 모든 속성을 필수로
Required<T>는 반대입니다. 선택적 속성들을 모두 필수로 만듭니다.
// 새 파일: required-example.tsinterface UserDraft { name: string; email?: string; age?: number;}// Required<UserDraft>의 타입:// {// name: string;// email: string; // 필수가 됩니다// age: number; // 필수가 됩니다// }function submitUserForm(user: Required<UserDraft>): void { // 모든 필드가 반드시 채워져 있어야 합니다 console.log(`폼 제출: ${user.name}, ${user.email}, ${user.age}`);}submitUserForm({ name: "Alice", email: "[email protected]", age: 30 });// submitUserForm({ name: "Alice" }); // 오류 — email과 age가 필요합니다
Pick — 일부 속성만 선택하기
Pick<T, K>는 T에서 K에 해당하는 속성만 골라냅니다. K는 T의 키 유니온입니다.
// 새 파일: pick-example.tsinterface User { id: number; name: string; email: string; age: number; role: "admin" | "user" | "guest";}// Pick<User, "id" | "name">의 타입:// {// id: number;// name: string;// }type UserSummary = Pick<User, "id" | "name">;function displayUser(user: UserSummary): string { return `[${user.id}] ${user.name}`;}const summary: UserSummary = { id: 1, name: "Alice" };console.log(displayUser(summary)); // [1] Alice// 목록 표시 — id와 name만 필요합니다const users: UserSummary[] = [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Carol" },];users.forEach((u) => console.log(displayUser(u)));// [1] Alice// [2] Bob// [3] Carol
API 응답에서 필요한 필드만 뽑아서 화면에 표시할 때 자주 씁니다.
Omit — 일부 속성을 제외하기
Omit<T, K>는 T에서 K에 해당하는 속성을 제거합니다. Pick의 반대입니다.
// 새 파일: omit-example.tsinterface User { id: number; name: string; email: string; age: number; role: "admin" | "user" | "guest";}// Omit<User, "id" | "role">의 타입:// {// name: string;// email: string;// age: number;// }type CreateUserInput = Omit<User, "id" | "role">;// 새 사용자를 만들 때는 id(서버 생성)와 role(기본값 사용)을 받지 않습니다function createUser(input: CreateUserInput): User { return { ...input, id: Math.floor(Math.random() * 1000), role: "user", // 기본 역할 };}const newUser = createUser({ name: "Dave", email: "[email protected]", age: 28,});console.log(newUser);// { name: "Dave", email: "[email protected]", age: 28, id: ..., role: "user" }
Pick vs Omit 선택 기준: 남길 속성이 적으면 Pick, 제거할 속성이 적으면 Omit을 씁니다.
Record — 키-값 타입 만들기
Record<K, V>는 키 타입이 K이고 값 타입이 V인 객체 타입을 만듭니다.
// 새 파일: record-example.ts// 각 역할에 대한 권한 목록type Role = "admin" | "user" | "guest";type Permission = "read" | "write" | "delete";const permissions: Record<Role, Permission[]> = { admin: ["read", "write", "delete"], user: ["read", "write"], guest: ["read"],};function hasPermission(role: Role, permission: Permission): boolean { return permissions[role].includes(permission);}console.log(hasPermission("admin", "delete")); // trueconsole.log(hasPermission("guest", "write")); // falseconsole.log(hasPermission("user", "read")); // true// 캐시 저장소 — 키는 string, 값은 어떤 타입이든type Cache = Record<string, unknown>;const cache: Cache = {};cache["user:1"] = { id: 1, name: "Alice" };cache["config"] = { debug: true };
내부 원리. Record<K, V>는 다음과 같이 정의됩니다.
// TypeScript 내장 타입 (참고용)type Record<K extends keyof any, T> = { [P in K]: T;};
K extends keyof any는 K가 유효한 객체 키 타입(string, number, symbol)이어야 한다는 제약입니다.
유틸리티 타입 조합하기
유틸리티 타입은 조합해서 쓸 수 있습니다.
// 새 파일: utility-combination.tsinterface User { id: number; name: string; email: string; age: number; role: "admin" | "user" | "guest";}// 업데이트할 때 — id와 role은 바꿀 수 없고, 나머지는 일부만 변경 가능type UserUpdateInput = Partial<Omit<User, "id" | "role">>;// 결과:// {// name?: string;// email?: string;// age?: number;// }function updateUser(id: number, updates: UserUpdateInput): void { console.log(`User ${id} 업데이트:`, updates);}updateUser(1, { name: "Alice Updated" });updateUser(2, { age: 31 });updateUser(3, { email: "[email protected]", age: 25 });// updateUser(4, { role: "admin" }); // 오류 — role은 바꿀 수 없습니다
Partial<Omit<User, "id" | "role">>은 "User에서 id와 role을 빼고, 나머지를 전부 선택적으로"입니다. 실무에서 자주 보이는 패턴입니다.
더 알아두면 좋은 유틸리티 타입
자주 쓰이는 다른 유틸리티 타입들도 있습니다.
// 새 파일: more-utility-types.ts// Readonly — 모든 속성을 읽기 전용으로interface Config { host: string; port: number;}const config: Readonly<Config> = { host: "localhost", port: 3000 };// config.host = "example.com"; // 오류 — 읽기 전용입니다// ReturnType — 함수의 반환 타입 추출function getUserInfo() { return { name: "Alice", age: 30, active: true };}type UserInfo = ReturnType<typeof getUserInfo>;// UserInfo의 타입은 { name: string; age: number; active: boolean }const info: UserInfo = { name: "Bob", age: 25, active: false };// Parameters — 함수의 매개변수 타입 추출function createUser(name: string, age: number, role: string): void { console.log(name, age, role);}type CreateUserParams = Parameters<typeof createUser>;// CreateUserParams의 타입은 [name: string, age: number, role: string]const params: CreateUserParams = ["Alice", 30, "admin"];createUser(...params);
ReturnType과 Parameters는 기존 함수의 타입을 재사용할 때 유용합니다. 함수 시그니처가 바뀌면 이 타입들도 자동으로 업데이트됩니다.
언제 어떤 유틸리티 타입을 쓸까
정리하면 다음과 같습니다.
| 유틸리티 타입 | 쓰는 상황 |
|---|---|
Partial<T> |
업데이트 입력, 일부 필드만 채우는 폼 |
Required<T> |
최종 검증 단계, 모든 필드가 채워졌음을 보장 |
Pick<T, K> |
화면 표시용 타입, 필요한 필드만 추출 |
Omit<T, K> |
생성 입력 타입, 서버 생성 필드 제외 |
Record<K, V> |
딕셔너리, 설정 맵, 캐시 |
Readonly<T> |
변경 불가 설정값, 상수 객체 |
ReturnType<T> |
함수 반환 타입을 다른 곳에서 재사용 |
레고 변환 블록을 사용해봤습니다. User 타입 하나에서 UserSummary, CreateUserInput, UserUpdateInput 등 상황에 맞는 다양한 타입을 만들었습니다. 새 인터페이스를 처음부터 작성하지 않아도 됩니다.
유틸리티 타입들은 모두 제네릭으로 만들어져 있습니다. 앞서 배운 <T>, extends, keyof가 조합된 결과입니다. TypeScript 표준 라이브러리 코드를 보면 이 장에서 배운 내용들이 그대로 쓰이고 있습니다.
다음 장에서는 제네릭 인터페이스와 클래스를 만들어봅니다. API 응답을 감싸는 ApiResponse<T>, 데이터를 관리하는 Repository<T> 같은 실전 패턴을 다룹니다.