Pick<T, K>, Omit<T, K>, Partial<T>는 타입스크립트가 기본으로 제공하는 제네릭 유틸리티 타입(utility type)으로, 이미 있는 객체 타입 T에서 일부 속성 줄만 골라내거나(Pick), 특정 줄만 빼거나(Omit), 모든 줄을 선택 속성(?)으로 바꾼(Partial) 새 타입을 만듭니다.
주문을 나타내는 Order에는 번호(id), 메뉴(item), 수량(qty) 세 줄이 있습니다. 그런데 새 주문을 접수할 때는 아직 번호가 없어 id를 빼야 하고, 주문을 고칠 때는 바꿀 속성만 넘기므로 모든 줄이 선택 속성이어야 합니다. 이때 파생 타입들을 손으로 따로 적지 않고 Order 하나에서 만들어 쓰면, 나중에 Order가 바뀔 때 모든 파생 타입이 한꺼번에 맞춰집니다.
1Pick과 Omit — 속성 줄 고르기와 빼기
제네릭 타입 — Array·Map·내 타입에서 Map<K, V>의 두 타입 칸을 채웠듯이, Pick과 Omit도 첫째 칸에 원래 객체 타입을, 둘째 칸에 속성 이름(또는 이름들의 유니언)을 채웁니다(검사 규칙 4 · 7).
Pick<Order, 'item' | 'qty'>:Order에서'item'과'qty'줄만 골라 남깁니다.Omit<Order, 'id'>:Order에서'id'줄만 빼고 나머지를 남깁니다.
interface Order {
id: number;
item: string;
qty: number;
}
type Summary =
Pick<Order, 'item' | 'qty'>;
type NewOrder =
Omit<Order, 'id'>;
const s: Summary = {
item: '라테',
qty: 2,
};
console.log(s.item, s.qty);
라테 2만약 Omit<Order, 'id'>로 id를 빼 만든 NewOrder 자리에, 새로 쓴 객체 리터럴로 id: 1을 함께 넣으면 어떻게 될까요?
interface Order {
id: number;
item: string;
qty: number;
}
type NewOrder =
Omit<Order, 'id'>;
type OrderPatch =
Partial<Order>;
const next: NewOrder = {
id: 1,
item: '라테',
qty: 2,
};
main.ts(12,3): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'NewOrder'.NewOrder 모양에는 item과 qty 두 줄만 남아 있으므로, 12번 줄의 id: 1은 새로 쓴 객체의 남는 속성에서 본 TS2353으로 막힙니다. 또한 Pick의 둘째 칸에는 keyof Order에 있는 속성 이름만 넣을 수 있어서, 없는 이름을 적으면 그 자리에서 바로 오류가 납니다.
2Partial — 모든 줄을 선택 속성(?)으로 바꾸기
주문을 일부만 고치는 함수에서는 item만 바꿀 수도 있고 qty만 바꿀 수도 있습니다. Partial<Order>는 Order의 모든 속성 줄 뒤에 ?를 붙인 것(id?: number, item?: string, qty?: number)과 같은 타입을 만듭니다(선택 속성과 readonly).
| 유틸리티 타입 | 하는 일 | Order(id, item, qty)에 쓴 결과 |
|---|---|---|
Pick<Order, 'item'> | 적은 속성 줄만 골라 냄 | { item: string } |
Omit<Order, 'id'> | 적은 속성 줄만 뺌 | { item: string; qty: number } |
Partial<Order> | 모든 줄을 선택(?)으로 바꿈 | { id?: number; item?: string; qty?: number } |
유틸리티 타입의 가장 큰 장점은 원래 타입 하나만 고치면 파생 타입들이 저절로 따라 바뀐다는 점입니다. 아래 코드처럼 Order에 memo: string; 줄을 새로 더하면 10번 줄 next: NewOrder는 검사를 지나갈까요? 답을 정한 뒤 펼쳐 보세요.
interface Order {
id: number;
item: string;
qty: number;
memo: string;
}
type NewOrder =
Omit<Order, 'id'>;
const next: NewOrder = {
item: '라테',
qty: 2,
};
검사 결과 보기
main.ts(10,7): error TS2741: Property 'memo' is missing in type '{ item: string; qty: number; }' but required in type 'NewOrder'.예상과 다르면 그곳이 배울 곳입니다.
Order에 memo: string을 더하는 순간 NewOrder(Omit<Order, 'id'>)도 item, qty, memo 세 줄을 요구하는 타입으로 자동 변경됩니다. 그래서 memo를 아직 챙기지 않은 10번 줄을 검사기가 TS2741로 즉시 찾아 줍니다.
예제 — 주문 접수와 수정 함수 만들기
Order 하나에서 새 주문 타입 NewOrder(Omit<Order, 'id'>)와 수정할 내용 타입 OrderPatch(Partial<NewOrder>)를 만들어, 주문을 만들고 고치는 작은 완성품입니다.
JS 다시 보기: 객체 리터럴 안의 { ...o, ...patch }는 앞 객체 o의 속성들을 복사한 뒤 patch에 들어 있는 속성만 덮어쓴 새 객체를 만듭니다(단축 속성과 나머지 모으기).
interface Order {
id: number;
item: string;
qty: number;
}
type NewOrder =
Omit<Order, 'id'>;
type OrderPatch =
Partial<NewOrder>;
앞 코드에 이어 주문 생성 함수 createOrder와 주문 수정 함수 updateOrder를 작성합니다.
function createOrder(
id: number,
input: NewOrder,
): Order {
return { id, ...input };
}
function updateOrder(
o: Order,
patch: OrderPatch,
): Order {
return { ...o, ...patch };
}
이어서 주문을 하나 만들고 수량(qty)만 3으로 고쳐 봅니다.
const first = createOrder(1, {
item: '라테',
qty: 2,
});
const updated = updateOrder(
first,
{ qty: 3 },
);
console.log(
updated.id,
updated.item,
updated.qty,
);
검사를 지나갈까요? Node로 돌리면 무엇이 찍힐까요?
검사 결과와 실행 결과 보기
1 라테 3예상과 다르면 그곳이 배울 곳입니다.
createOrder는 id가 없는 NewOrder를 받아 id를 붙여 Order를 돌려주고, updateOrder는 NewOrder의 속성들을 선택으로 바꾼 OrderPatch를 받아 { qty: 3 } 한 줄만 넘겨도 검사를 지납니다. OrderPatch를 Partial<NewOrder>로 두었기 때문에 수정할 때 실수로 { id: 99 }를 넘기면 남는 속성으로 막아 주는 효과도 얻습니다.
자주 틀리는 곳
틀린 생각: NewOrder나 OrderPatch는 줄이 몇 개 안 되니 그때그때 손으로 다시 적는 편이 낫다.
처음 만들 때는 손으로 복사해 적는 것이 쉬워 보입니다. 하지만 나중에 Order의 qty 이름을 count로 바꾸거나 memo 줄을 더할 때, 손으로 따로 적어 둔 타입들은 오류가 나지 않아 옛날 모양 그대로 남고 프로그램이 어긋납니다.
맞는 규칙: 한 개념에서 파생되는 타입은 원래 타입 하나(Order)를 기준으로 Pick·Omit·Partial을 써서 만듭니다(2절).
틀린 생각: Partial<Order>로 바꾸면 모든 속성이 선택이 되어 아무 값이나 넣어도 검사를 지난다.
속성을 안 넣어도 통과하니 타입 검사 자체가 느슨해진 것처럼 보입니다.
interface Order {
item: string;
qty: number;
}
const p: Partial<Order> = {
qty: '두 잔',
};
main.ts(6,3): error TS2322: Type 'string' is not assignable to type 'number'.맞는 규칙: Partial<Order>는 각 속성을 생략할 수 있게(undefined 허용) 바꿀 뿐, 넣은 속성의 값 타입(qty: number)이나 없는 속성 이름( TS2353)은 원래대로 엄격히 검사합니다.
틀린 생각: Partial<T>를 쓰면 속성 안에 들어 있는 안쪽 객체의 속성들까지 모두 선택(?)으로 바뀐다.
Partial이 객체 전체를 끝까지 훑어 줄 것 같지만, 유틸리티 타입은 겉 한 겹의 속성 줄에만 ?를 붙입니다. 안쪽 객체까지 선택으로 바꾸려면 안쪽 타입에도 따로 Partial을 써야 합니다.
틀린 생각: 유틸리티 타입을 서너 겹씩(Partial<Pick<Omit<...>>>) 중첩해 한 줄로 적을수록 좋다.
여러 겹을 한꺼번에 포개 적으면 오류 문장이 길어지고 읽는 사람이 어떤 줄이 남았는지 머릿속으로 따라가기 어렵습니다.
맞는 규칙: 예제에서 NewOrder를 먼저 이름 붙여 만들고 Partial<NewOrder>를 적었듯이, 두 겹을 넘기지 않고 중간 타입에 뜻이 드러나는 이름을 붙여 나눕니다(참고: 이 유틸리티 타입들의 내부 구현을 뜯어보면 keyof를 도는 매핑 타입으로 되어 있지만, 실무에서는 기본 유틸리티 타입을 조합해 쓰는 것만으로 충분합니다).
확인 문제
1. 다음 코드에서 9번 줄은 검사를 지나갈까요? 지나가지 못하면 몇 번 줄에서 어떤 이유로 막힐까요?
interface Member {
id: number;
name: string;
email: string;
}
type PublicProfile =
Pick<Member, 'name'>;
const p: PublicProfile = {
name: '민지',
email: 'a@b.com',
};
힌트
Pick<Member, 'name'>이 남기는 속성 줄이 몇 개인지 보고, 새로 쓴 객체 리터럴에 다른 줄이 더 있는지 봅니다.
정답
main.ts(11,3): error TS2353: Object literal may only specify known properties, and 'email' does not exist in type 'PublicProfile'.11번 줄에서 TS2353으로 막힙니다. Pick<Member, 'name'>은 name 한 줄만 골라낸 타입이라 email은 남는 속성으로 잡힙니다.
흔한 오답은 '원래 Member에 email이 있으니 지나간다'입니다. Pick으로 골라내지 않은 줄은 새 타입에서 빠집니다.
2. 도서관 책 타입 Book의 모든 속성을 선택 속성(?)으로 바꾼 BookDraft 타입을 만들려고 합니다. 6번 줄 빈칸을 채우세요.
interface Book {
title: string;
pages: number;
}
type BookDraft = ____________;
const d: BookDraft = {
title: '어린 왕자',
};
console.log(d.title);
힌트
모든 줄을 선택 속성으로 바꾸는 유틸리티 타입 이름 뒤에 <Book>을 적습니다(2절).
정답
빈칸은 Partial<Book>입니다.
interface Book {
title: string;
pages: number;
}
type BookDraft =
Partial<Book>;
const d: BookDraft = {
title: '어린 왕자',
};
console.log(d.title);어린 왕자다르게 써도 되는 곳: Pick<Book, 'title'>이나 Omit<Book, 'pages'>를 적어도 위 d는 통과하지만, 그러면 { pages: 150 }만 있는 초안은 넣을 수 없습니다. 모든 줄을 선택으로 여는 것은 Partial<Book>입니다.
흔한 오답은 Optional<Book>처럼 없는 이름을 적는 것입니다(TS2304).
3. User 인터페이스에 줄이 네 개 있습니다. 여기서 비밀번호(pw) 한 줄만 빼고 나머지 세 줄(id, name, role)을 그대로 가지는 타입을 만들 때, 가장 간결하고 나중에 User에 새 줄(phone)이 추가되어도 자동으로 포함되는 표기를 고르세요.
interface User {
id: number;
name: string;
role: string;
pw: string;
}
- ①
Omit<User, 'pw'> - ②
Pick<User, 'id' | 'name' | 'role'> - ③
Partial<User>
힌트
숨길 줄('pw') 하나만 콕 집어 빼서, 나중에 다른 줄이 늘어나도 그대로 남게 하는 유틸리티 타입을 고릅니다.
정답
①입니다. Omit<User, 'pw'>로 적으면 'pw' 한 줄만 빼므로, 나중에 User에 phone: string이 더해져도 Omit<User, 'pw'>에 자동으로 포함됩니다.
- ② 지금은 결과 모양이 같지만, 나열할 이름이 길고 나중에
User에phone이 추가될 때 손으로| 'phone'을 더해야 합니다. - ③
pw가 빠지지 않고 선택 속성(pw?: string)으로 남아 있습니다.
컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요.
예제의 세 코드 블록을 합쳐 main.ts에 저장한 뒤, interface Order에 memo?: string; 줄을 더하고 { qty: 3, memo: '샷 추가' }로 고쳐 npx tsc와 node main.ts를 돌려 보세요. 다음 레슨은 Record — 키 타입으로 만드는 객체 타입입니다.
검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.