판별 유니언(discriminated union)은 모든 갈래가 같은 이름의 속성을 갖고, 그 속성의 값이 갈래마다 다른 리터럴인 유니언입니다. 그 속성 하나만 확인하면 값이 어느 갈래인지 가를 수 있습니다. 갈래를 가르는 이 속성을 이 과목에서는 갈래 속성(discriminant property)이라고 부릅니다.
카페 주문에는 추가 금액이 있는 음료와 없는 음식이 섞여 들어옵니다. 두 타입을 유니언 Drink | Food로 받으면 검사기는 둘이 함께 가진 속성만 쓰게 합니다(유니언 — 여럿 가운데 하나).
추가 금액을 쓰려면 음료인지 먼저 확인해 좁혀야 하는데, 객체는 typeof가 모두 'object'라 음료와 음식을 가르지 못합니다. 그래서 갈래마다 값이 다른 속성을 하나 두고, 그 값을 확인합니다.
이 단원을 이미 안다면 단원 연습 문제의 진단 문항부터 풀고 틀린 레슨만 보세요.
1갈래 속성 — 갈래마다 값이 하나뿐인 속성
음료와 음식 주문의 타입입니다. 두 interface 모두 kind 속성을 가졌고, 그 타입은 값 하나짜리 리터럴 타입입니다(리터럴 유니언으로 값 고르기).
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;
Drink의 kind 자리에는 'drink'만, Food의 kind 자리에는 'food'만 들어가, kind 값을 보면 어느 갈래인지 정해집니다. 이것이 갈래 속성입니다. 자료에 따라 판별 속성, 판별 유니언은 식별 유니언이나 태그된 유니언(tagged union)이라고도 부릅니다.
앞 코드에 이어, 주문 하나의 금액을 돌려줄 cost를 씁니다.
function cost(o: Order) {
return o.price + o.extra;
}
main.ts(12,22): error TS2339: Property 'extra' does not exist on type 'Order'. Property 'extra' does not exist on type 'Food'.11번 줄에서 o의 지금 타입은 Order, 곧 Drink | Food입니다. 두 타입이 함께 가진 kind와 price는 확인 없이도 쓰지만, extra는 Drink에만 있습니다.
유니언 레슨에서 본 TS2339입니다. 들여 쓴 둘째 줄이 까닭을 말합니다 — o는 Food일 수도 있는데 Food에는 extra가 없습니다.
2갈래 속성을 확인하면 그 갈래로 좁혀진다
o.kind === 'drink'가 참인 갈래에는 kind가 'drink'인 값, 곧 Drink만 올 수 있어 검사기는 그 갈래에서 o를 Drink로 좁혀 봅니다. typeof 대신 갈래 속성을 확인했을 뿐, 좁히기 — typeof와 타입 추적 표에서 본 좁히기와 같습니다(검사 규칙 5).
타입 선언(1절과 같음)
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;1절과 같은 타입 선언에 이어 cost를 if로 씁니다. 이 코드는 검사를 지날까요? 막힌다면 몇 번 줄일까요?
function cost(o: Order) {
if (o.kind === 'drink') {
return o.price + o.extra;
}
return o.price + o.extra;
}
검사 결과 보기
main.ts(15,22): error TS2339: Property 'extra' does not exist on type 'Food'.예상과 다르면 그곳이 배울 곳입니다. 13번 줄은 지나가고 15번 줄만 막힙니다.
검사기는 o에 무엇이 올지 모르니 12번 줄에서 두 길을 다 봅니다. 갈래 안으로 들어가는 길에서 o는 Drink라 13번 줄의 o.extra가 지나갑니다. 그 길은 13번 줄의 return으로 끝나 아래로 오지 않습니다.
그래서 15번 줄로 오는 길은 12번 줄에서 갈래를 건너뛴 길 하나뿐이고, 그 길의 o는 Drink가 아니니 Food입니다. 손으로 쓰는 타입 추적 표로는 이렇습니다.
| 줄 | 이름 | 지금 타입 |
|---|---|---|
| 11 | o | Order |
| 12 | o | Order → 참 Drink 거짓 Food |
| 13 | o | Drink — return으로 끝남 |
| 15 | o | 만나는 줄 — 참은 오지 않음 + 거짓 Food → Food |
Property 'extra'는 '속성 extra', does not exist on type 'Food'는 'Food 타입에 없다'입니다. 곧 음식 주문에서 추가 금액을 찾았다는 뜻입니다.
15번 줄을 return o.price;로 고치면 검사 오류가 없습니다. 음료는 가격에 추가 금액을 더하고, 음식은 가격 그대로 돌려줍니다.
3switch로 갈래마다 나누기
JS 다시 보기: switch는 값이 === 연산자로 같은 case부터 break나 return을 만날 때까지 실행합니다(JS 'switch와 조건 연산자').
타입 선언(1절과 같음)
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;1절과 같은 타입 선언에 이어 cost를 switch로 씁니다. 검사 오류가 없습니다.
function cost(o: Order) {
switch (o.kind) {
case 'drink':
return o.price + o.extra;
case 'food':
return o.price;
}
}
case 'drink':도 o.kind === 'drink'와 같은 확인이라, 검사기는 case마다 그 아래 줄에서 o를 그 갈래로 좁힙니다.
어느 case로 갈지는 실행할 때 값이 정하지만, 검사기는 값을 모르니 case 아래 줄을 모두 읽습니다(검사 규칙 5).
예제
타입 추적 표로 갈래 읽기
3절의 cost를 좁히기 레슨에서 배운 타입 추적 표로 씁니다. case마다 갈래 안의 지금 타입을 적고, 갈래가 끝나는 줄도 빼지 않습니다 — 반만 쓴 표는 안 쓴 것과 같습니다.
| 줄 | 이름 | 지금 타입 |
|---|---|---|
| 11 | o | Order |
| 13 | o | case 'drink' 갈래 안 Drink |
| 14 | o | Drink — return으로 끝남 |
| 15 | o | case 'food' 갈래 안 Food |
| 16 | o | Food — return으로 끝남 |
14번 줄의 o.extra는 Drink에 있어 지나갑니다. 16번 줄에서 o.extra를 썼다면 2절과 같은 TS2339로 막힙니다.
두 길이 모두 return으로 끝나 switch 뒤로 오는 길이 없으니, 만나는 줄 행은 쓸 것이 없습니다.
자주 틀리는 곳
틀린 생각: 음료와 음식을 한 interface에 담고, 한쪽에만 있는 속성은 ?로 두면 된다.
속성 하나만 다르니 한 타입에 extra?: number를 두는 쪽이 짧아 보입니다.
interface Order {
kind: string;
price: number;
extra?: number;
}
function cost(o: Order) {
if (o.kind === 'drink') {
return o.price + o.extra;
}
return o.price;
}
main.ts(9,22): error TS18048: 'o.extra' is possibly 'undefined'.8번 줄에서 kind를 확인해도 o는 같은 Order 하나라, 9번 줄의 o.extra는 그대로 number | undefined입니다. 음료인데 extra를 빠뜨린 값도 이 타입에 들어갑니다.
맞는 규칙: 한 타입 안의 선택 속성은 다른 속성(kind)을 확인해도 undefined가 든 타입 그대로라, 갈래를 확인해도 그 속성이 있다고 좁혀지지 않습니다(검사 규칙 5). 갈래마다 속성이 다르면 갈래마다 interface를 따로 두고 갈래 속성으로 묶습니다.
틀린 생각: 갈래 속성은 kind: string으로 적어도 된다.
값이 글자이니 타입도 string이면 될 것 같습니다. 그러나 string은 모든 글자의 모음이라, 두 갈래의 kind가 같은 모음이 됩니다.
interface Drink {
kind: string;
price: number;
extra: number;
}
interface Food {
kind: string;
price: number;
}
type Order = Drink | Food;
앞 코드에 이어 2절의 cost를 씁니다.
function cost(o: Order) {
if (o.kind === 'drink') {
return o.price + o.extra;
}
return o.price;
}
main.ts(13,24): error TS2339: Property 'extra' does not exist on type 'Order'. Property 'extra' does not exist on type 'Food'.Food의 kind도 'drink'일 수 있으니, 12번 줄의 확인이 참이어도 o는 Order 그대로입니다.
맞는 규칙: 갈래 속성은 갈래마다 값 하나짜리 리터럴 타입('drink'·'food')으로 적습니다. 그래야 그 값을 확인한 갈래에 한 타입만 남습니다(검사 규칙 5).
틀린 생각: 갈래 값은 대소문자가 달라도 같은 갈래로 본다.
사람에게는 'Drink'와 'drink'가 같은 말이지만, 검사기에게 'drink' 자리는 그 글자 하나의 모음입니다.
타입 선언(1절과 같음)
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;1절과 같은 타입 선언에 이어 라테 주문을 만듭니다.
const latte: Order = {
kind: 'Drink',
price: 4500,
extra: 500,
};
main.ts(12,3): error TS2820: Type '"Drink"' is not assignable to type '"drink" | "food"'. Did you mean '"drink"'?'Drink' 값을 "drink" | "food" 자리에 넣을 수 없다는 TS2820이고, 끝의 Did you mean은 검사기의 짐작입니다. 확인하는 쪽에서 o.kind === 'Drink'로 잘못 써도 TS2367(두 타입에 겹치는 값이 없다)로 막힙니다.
맞는 규칙: 리터럴 타입 'drink'는 그 글자 하나만 든 모음이라, 대소문자가 다른 'Drink'는 들어가지 못합니다(검사 규칙 2). 갈래 값은 interface에 적은 글자 그대로 씁니다.
틀린 생각: case를 하나 빠뜨리면 검사기가 그 switch에서 알려 준다.
3절 cost에서 case 'food': 두 줄을 지워도 검사 오류가 없습니다. 음식 주문은 switch를 지나 함수 끝에 닿아 undefined를 돌려주고, 검사기는 cost가 돌려주는 타입에 undefined를 더할 뿐입니다(number | undefined).
플레이그라운드에서는 TS7030(모든 길이 값을 돌려주지는 않는다)이 나옵니다. 이 과목 설정에서 꺼 둔 검사(noImplicitReturns)를 켜 두었기 때문입니다.
맞는 규칙: 검사기는 맡는 case가 없는 갈래를 오류로 보지 않습니다. 그 갈래는 switch를 지나갈 뿐입니다. 빠진 갈래를 알려 달라고 하는 법은 빠짐없이 — never로 갈래 검사에서 배웁니다.
확인 문제
1. 2절 코드에서 12번 줄의 'drink'만 'food'로 바꿨습니다. 타입 선언은 2절과 같습니다. 검사를 지날까요? 막힌다면 몇 번 줄일까요?
interface Drink {
kind: 'drink';
price: number;
extra: number;
}
interface Food {
kind: 'food';
price: number;
}
type Order = Drink | Food;
앞 코드에 이어 바꾼 cost를 씁니다.
function cost(o: Order) {
if (o.kind === 'food') {
return o.price + o.extra;
}
return o.price + o.extra;
}
힌트
12번 줄에서 길이 둘로 갈립니다. 13번 줄과 15번 줄에서 o의 지금 타입을 따로 적어 봅니다.
정답
main.ts(13,24): error TS2339: Property 'extra' does not exist on type 'Food'.13번 줄에서 막히고, 15번 줄은 지나갑니다. 12번 줄의 확인이 참인 길에서 o는 Food인데 Food에는 extra가 없습니다. 그 길은 13번 줄의 return으로 끝나므로, 15번 줄에는 확인이 거짓인 길만 와서 o는 Drink입니다.
흔한 오답은 '13번 줄과 15번 줄 둘 다'입니다. 갈래를 나오면 o가 선언 타입 Order로 돌아간다고 본 것입니다(갈래를 나오면 선언 타입이라는 생각). 15번 줄에는 return하지 않은 길만 오고, 그 길의 o는 Drink입니다.
2. 원두를 무게로 파는 주문 Bean을 판별 유니언에 더하려 합니다. 빈칸에 들어갈 갈래 속성의 타입을 쓰세요.
interface Bean {
kind: ____;
price: number;
grams: number;
}
type Order =
| Drink
| Food
| Bean;
힌트
Drink·Food의 kind 자리에 무엇을 적었는지 1절에서 봅니다.
정답
'bean'입니다. kind: 'bean';처럼 다른 갈래와 겹치지 않는 값 하나짜리 리터럴 타입을 적습니다.
다르게 써도 되는 곳: 'beans'·'coffee-bean'처럼 'drink'·'food'와 다른 글자면 됩니다. 검사기는 글자의 뜻을 보지 않고 다른 값인지만 봅니다.
흔한 오답은 string입니다. 값이 글자이니 타입도 string이면 된다고 본 것입니다(자주 틀리는 곳 둘째). string은 'drink'·'food'까지 품어, kind === 'bean'으로 확인해도 Bean으로 좁혀지지 않습니다.
3. 장보기 목록입니다. 과일은 무게(kg)로, 묶음 상품은 개수로 잽니다. 이 코드는 11번 줄에서 막힙니다. 과일에 없는 개수를 만들지 않고 고치는 길을 고르세요.
interface Fruit {
kind: 'fruit';
kg: number;
}
interface Pack {
kind: 'pack';
count: number;
}
type Item = Fruit | Pack;
function size(i: Item) {
return i.count + '개';
}
main.ts(11,12): error TS2339: Property 'count' does not exist on type 'Item'. Property 'count' does not exist on type 'Fruit'.- ①
if (i.kind === 'pack')로 확인한 갈래에서만i.count를 쓰고, 나머지 갈래는i.kg로 돌려준다. - ②
Fruit에도count: number를 더한다. - ③ 두 interface의
kind를string으로 바꾼다.
힌트
11번 줄에서 i의 지금 타입이 무엇인지, 고친 뒤에는 무엇이 되는지 봅니다.
정답
①입니다. 확인이 참인 갈래에서 i는 Pack, return 뒤 14번 줄에서는 Fruit입니다.
interface Fruit {
kind: 'fruit';
kg: number;
}
interface Pack {
kind: 'pack';
count: number;
}
type Item = Fruit | Pack;
function size(i: Item) {
if (i.kind === 'pack') {
return i.count + '개';
}
return i.kg + 'kg';
}- ② 검사는 지나지만, 과일 값마다 개수를 적어야 하는 타입이 됩니다. 실제 데이터와 다른 모양을 적었습니다.
- ③ 갈래 속성이
string이면 확인해도 좁혀지지 않아 그대로 막힙니다(자주 틀리는 곳 둘째).
컴퓨터에서 해 보기(선택) — 점수에 들지 않습니다. 폰으로만 보고 있다면 건너뛰세요.
3절의 타입 선언과 cost를 main.ts로 저장하고 npx tsc로 검사합니다. 아무것도 찍히지 않으면 검사 오류가 없는 것입니다. 그다음 7번 줄의 kind: 'food'를 kind: 'drink'로 바꾸면 몇 번 줄에서 막힐지 먼저 적고, 다시 검사해 맞춰 봅니다.
검사하고 돌리는 법은 내 컴퓨터에서 검사하고 돌리기에 있습니다.