Partial·Pick·Omit·Record, 내장 유틸리티 타입 뜯어보기
유틸리티 타입은 결국 제네릭이다
TypeScript가 기본으로 제공하는 Partial, Pick, Omit, Record 같은 유틸리티 타입들은 신기한 내장 기능이 아니라, 여러분이 지금까지 배운 제네릭·keyof·매핑된 타입을 조합해 만든 평범한 타입 별칭(type alias)입니다. lib.es5.d.ts를 열어보면 실제 정의를 볼 수 있습니다.
type Partial<T> = {
[P in keyof T]?: T[P];
};[P in keyof T]는 T의 모든 키를 순회하겠다는 뜻이고, ?는 각 속성을 선택적으로 만들겠다는 뜻입니다. 즉 Partial<T>는 "T와 똑같은 모양인데, 모든 속성이 선택적인 타입"입니다. 매핑된 타입 문법 자체는 다음 챕터에서 자세히 다루니, 여기서는 "유틸리티 타입도 우리가 만들 수 있는 수준의 코드"라는 감각만 가져가면 됩니다.
자주 쓰는 다섯 가지와 실제 쓰임
interface Article {
id: number;
title: string;
body: string;
draft: boolean;
}
type ArticleDraft = Partial<Article>;
// 모든 속성이 선택적. 폼 입력 중간 상태를 표현할 때 쓴다.
type ArticleSummary = Pick<Article, "id" | "title">;
// id와 title만 남긴다. 목록 화면처럼 일부 필드만 필요할 때 쓴다.
type ArticleInput = Omit<Article, "id">;
// id를 제외한 나머지. 생성 요청 바디처럼 서버가 채워줄 필드를 뺄 때 쓴다.
type ReadonlyArticle = Readonly<Article>;
// 모든 속성이 readonly. 한 번 만들면 바뀌지 않아야 하는 값에 쓴다.
type ArticleStatusMap = Record<"draft" | "published" | "archived", number>;
// 각 상태별 개수를 세는 객체. { draft: number; published: number; archived: number }이 다섯 개 중 실무에서 가장 자주 손이 가는 건 Pick과 Omit입니다. 둘 다 "이미 있는 타입에서 필드를 골라내는" 같은 목적을 반대 방향으로 접근합니다 — 남길 것을 나열하느냐, 뺄 것을 나열하느냐의 차이입니다. 필드가 몇 개 안 남는다면 Pick, 대부분을 그대로 쓰고 한둘만 뺀다면 Omit이 코드를 더 짧게 만듭니다.
Pick과 Omit을 잘못 쓰면 타입이 원본과 어긋난다
여기서 실무에서 실제로 겪은 문제 하나를 짚고 넘어가겠습니다. Omit<Article, "id">로 만든 ArticleInput은 원본 Article 인터페이스가 바뀌면 자동으로 따라 바뀝니다. 이게 유틸리티 타입을 쓰는 이유입니다 — 필드를 손으로 다시 나열한 별도 타입을 만들면, Article에 필드가 추가될 때 그 타입은 조용히 구식이 됩니다.
// 나쁜 예: Article과 별개로 손으로 다시 정의
interface ArticleInputBad {
title: string;
body: string;
draft: boolean;
}
// Article에 필드가 추가되면 ArticleInputBad는 아무 경고 없이 뒤처진다
// 좋은 예: Article에서 파생
type ArticleInputGood = Omit<Article, "id">;
// Article에 필드가 추가되면 ArticleInputGood도 자동으로 그 필드를 포함한다타입을 "원본에서 파생시킬 것인가, 독립적으로 정의할 것인가"는 사소해 보이지만 팀 규모가 커질수록 차이가 큽니다. 한 사람이 Article을 수정했을 때 관련된 다른 타입들이 자동으로 맞춰지길 원한다면 유틸리티 타입으로 파생시키고, 의도적으로 서로 독립적이어야 하는 타입이라면 (예: 외부 API 응답 스펙처럼 우리가 통제할 수 없는 것) 손으로 따로 정의하는 게 맞습니다.
Record로 유니온을 강제로 다 채우게 만들기
Record는 단순히 "객체 타입"을 표현하는 것 이상의 역할을 합니다. 유니온 타입의 모든 케이스를 빠짐없이 다뤘는지 컴파일러가 검사하게 만드는 용도로 자주 씁니다.
type Status = "draft" | "published" | "archived";
const statusLabel: Record<Status, string> = {
draft: "임시저장",
published: "발행됨",
archived: "보관됨",
// 하나라도 빠지면 컴파일 에러
};Status에 나중에 "deleted"가 추가된다면, statusLabel은 그 즉시 컴파일 에러를 냅니다 — 새 상태에 대한 라벨을 빠뜨렸다는 뜻입니다. if나 switch로 각 상태를 분기 처리하는 코드보다 이런 객체 매핑이 "모든 케이스를 다뤘는가"를 컴파일러가 대신 확인해준다는 점에서 더 안전합니다.
내장 유틸리티 타입은 몇 개 외워서 쓰는 게 아니라, "이미 있는 타입을 어떻게 변형할 것인가"에 대한 아주 작은 언어라고 보는 게 맞습니다. 다음 챕터에서는 이 변형 문법, 즉 매핑된 타입을 직접 만드는 법을 다룹니다.