
Discriminated Union Type은 공통된 리터럴 속성(값이 고정된 속성)을 가진 여러 객체 타입을 하나의 유니온 타입으로 묶은 것이다. 공통 속성을 통해 타입을 구분(Discriminate)할 수 있어서 조건문 등을 통해 안전하게 타입을 좁힐 수 있다.
interface Dog {
type : 'dog';
bark : ()=>void;
}
interface Cat {
type : 'cat';
meow : ()=>void;
}
type Pet = Dog | Cat;
const handlePet =(pet:Pet)=> {
switch(pet.type) {
case 'dog':
pet.bark();
break;
case 'cat':
pet.meow();
break;
}
const dog = {
type: 'dog',
bark : ()=> console.log('왈왈'),
}
const cat = {
type: 'cat',
meow : ()=> console.log('야옹'),
}
handlePet(dog); // '왈왈'
handlePet(cat); // '야옹'
위 예시처럼 type이라는 공통된 리터럴 속성을 갖고 있다면 handlePet함수 안에서 pet.type값을 기준으로 타입을 정확하게 좁힐 수 있다.
또한 case 'dog' 블록 안에서는 pet.을 입력하면 자동으로 bark()와 같이 dog타입의 속성만 자동완성으로 표시되기 때문에 생산성을 높일 수 있다.
만약 type이라는 공통된 리터럴 속성이 없다면 in연산자를 사용해 타입을 좁혀야 한다.
const handlePet=(pet:Pet)=> {
if ('bark' in pet) return pet.bark();
if ('meow' in pet) return pet.meow();
}
이러한 방식도 동작은 하지만 다음과 같은 단점이 있다.
만약 속성이 'bark'와 'meow' 뿐이라면 이렇게 해도 큰 문제는 없지만 더 많은 속성들이 타입 안에 있을 경우 속성들을 사용할 때 하나하나 in을 통해 확인해야한다.
속성이 추가되더라도 만약 'bark'가 dog에 유일하게 있는 속성이라면 'bark' in pet 을 통해 타입을 dog로 좁힐 수 있지만, 만약 wolf라는 타입이 추가돼서 bark속성을 갖게 된다면 dog로 타입을 좁히는 것이 불가능해진다.
또한 'bark' in pet과 같이 타입을 검증할 땐 자동완성이 뜨지 않는다는 점과 만약 bark 함수의 이름이 변경되더라도 'bark' in pet이 컴파일 에러 없이 그대로 남아서 버그를 유발할 수 있다.
Discriminated Union타입을 Switch문으로 좁힐 때 모든 case를 처리하지 않는다면 타입이 누락되는 상황이 발생할 수 있다.
이를 방지하기 위해 never 타입과 default 블록을 활용하면 타입이 누락될 경우 컴파일 타임에 오류를 발생시켜서 타입 누락을 막을 수 있다.
interface Dog {
type : 'dog';
bark : ()=>void;
}
interface Cat {
type : 'cat';
meow : ()=>void;
}
interface Bird {
type : 'bird';
chirp : ()=>void;
}
type Pet = Dog | Cat | Bird;
const handlePet =(pet:Pet)=> {
switch (pet.type) {
case 'dog':
pet.bark();
break;
case 'cat':
pet.meow();
break;
case 'bird':
pet.chirp();
break;
default:
const _exhaustiveCheck: never = pet;
return _exhaustiveCheck;
}
}
여기서 만약 새로운 타입인 Turtle이 Pet 타입에 추가되거나 bird타입이 빠지게 되면 컴파일 에러가 표시된다.



Turtle이 추가됐을 떈 pet.type의 default로 never가 아닌 Turtle이 와서 에러가 발생하고
Bird가 빠졌을 땐 Pet의 타입인 "dog" | "cat"과 일치하지 않는다는 오류가 표시되는 것을 볼 수 있다.

위와 같이 서로 공통된 요소를 갖고 있지만 조금씩 다른 요소들이 존재하는 UI를 하나의 컴포넌트로 구현하기 위해 Discriminated Union 타입을 사용했다.
// 공통된 props(기사님 이름, 리뷰 평점, 리뷰 수, 경력 등등)
interface BaseProps {
movingType : MovingType[];
moverName : string;
rating : number;
//... 그 외 공통된 속성들
}
interface CompletedQuoteProps extends BaseProps {
variant: 'quote';
subVariant: 'completed';
description?: string;
price?: number;
}
interface PendingQuoteProps extends BaseProps {
variant : 'quote';
subVariant : 'pending';
quoteId: string;
price? : number;
// ... 그 외 속성들
}
interface WrittenReviewProps extends BaseProps {
variant : 'review';
subVariant : 'written';
reviewContent : string;
writtenAt : Date;
}
interface PendingReviewProps extends BaseProps {
variant : 'review';
subVariant : 'pending';
onClickReviewButton : ()=>void;
// ... 그 외 속성들
}
type MoverInfoProps = CompletedQuoteProps | PendingQuoteProps
| WrittenReviewProps | PendingReviewProps;
먼저 공통으로 사용되는 props들을 BaseProps로 선언하고 모든 유형의 interface에서 extends를 통해 해당 interface를 확장하도록 했다.
그리고 variant속성을 discriminant(구분자)로 사용해 유형을 구분했다. 큰 구분자 안에서 세부 구분을 위해 subVariant를 추가했는데 이렇게 할 경우 variant가 quote일 땐 subVariant는 completed과pending중 선택할 수 있게 좁혀진다.
export default function MoverInfo(props : MoverInfoProps) {
return (
<div>
<공통된UI {...someProps} />
{props.variant === 'quote' && (
{ props.subVariant === 'completed' && (
<CompletedQuote에 해당하는 UI/>
)}
{props.variant === 'review' && (
{ props.subvariant === 'written' && (
<WrittenReview에 해당하는 UI/>
)}
// ... 그 외 UI
</div>
)
}
이렇게 컴포넌트 내에서 props의 variant,subVariant를 통해 표시할 UI를 선택할 수 있다.