기존 타입을 사용해서 새로운 타입을 정의하는 것
TS 에서의 타입 확장 방법 에는 extends, 교차 타입, 유니온 타입 이 있다.
TS 코드를 작성하다 보면 필연적으로 생기는 중복되는 타입을, 기존에 작성한 타입을 바탕으로 타입 확장을 함으로써 불필요한 코드 중복을 줄일 수 있다.
예를 들어, 장바구니 요소(BaseCartItem) 는 메뉴 요소 (BaseMenuItem) 가 가지는 모든 타입이 필요하다. 이때, 확장 을 활용하여 다음과 같이 타입을 정의할 수 있다.
// using interface
interface BaseMenuItem {
itemName : string | null;
itemImageUrl : string | null;
stock : number | null;
}
interface BaseCartItem extends BaseMenuItem {
quantity : number;
}
// using type
type BaseMenuItem {
itemName : string | null;
itemImageUrl : string | null;
stock : number | null;
}
type BaseCartItem = {
quantity : number;
} & BaseMenuItem;
BaseCartItem 이 BaseMenuItem 에서 확장되었다는 것을 extends, & 와 같은 구문으로 쉽게 확인//할 수 있는 것처럼 더 명시적인 코드를 작성할 수 있게 된다.
// using interface
interface BaseCartItem extends BaseMenuItem {
quantity : number;
}
// using type
type BaseCartItem = {
quantity : number;
} & BaseMenuItem;
요구 사항이 늘어날 때마다 앞서 정의한 BaseCartItem 을 활용하여, 타입 확장을 통해 새로운 CartItem 타입을 정의할 수 있다.
예를 들어, 이벤트 기간 동안만 사용하는 새로운 장바구니 요소 가 필요하다면 다음과 같이 BaseCartItem 을 확장하여 타입을 정의할 수 있다.
interface EventCartItem extends BaseCartItem {
orderable : boolean;
}
유니온 타입은 2개 이상의 타입을 조합하여 사용하는 방법이며, 집합 관점으로 보면 합집합으로 해석할 수 있다.
예를 들어, 다음과 같이 A 와 B 의 유니온 타입인 MyUnion 이 있다. A 타입과 B 타입의 모든 값은 MyUnion 타입의 값이 된다. 집합 관점에서 보면 집합 A 와 집합 B 의 모든 원소는 집합 MyUnion 의 원소라는 뜻이다.
type MyUnion = A | B;
주의할 점은, 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.
예를 들어, 다음과 같은 두 interface 의 유니온 타입 을 인자로 받는 함수가 있다.
interface CookingStep {
orderId: string;
price: number;
}
interface DeliveryStep {
orderId: string;
time: number;
distance: string;
}
function getDeliveryDistance(step: CookingStep | DeliveryStep) {
return step.distance;
// ERROR!
}
이 때 발생하는 에러는 TS 의 타입을 속성의 집합이 아닌, 값의 집합이라는 관점에서 봐야 이해할 수 있다.
속성의 집합
우선, 속성의 집합 이라는 관점으로 타입을 바라볼때 유니온 타입은 다음과 같은 모호함을 가진다. 공통된 속성은 orderId 뿐이므로, 각각 서로 다른 속성인 price, time, distance 을 유니온 타입 step 이 가지고 있는 지에 대해 확신할 수 없다.
또한 속성의 집합이라는 관점으로 합집합을 생각하면, 유니온 타입 step 은 price, time, distance 속성을 모두 가지고 있는 타입이라는 의미가 된다. 하지만, 실제로는 그렇지 않다.
이렇듯, 속성의 집합 이라는 관점으로 유니온 타입을 바라보면, 유니온 타입이 각 타입의 모든 속성을 가지고 있는 타입이라고 잘못 해석할 수 있다. 또한 그렇게 될 경우, 예제처럼 유니온 타입 step 이 distance 속성을 가지고있을 것이라고 확신하는 잘못된 경우가 발생할 수 있다.
값의 집합
값의 집합 이라는 관점으로 타입을 바라본다면, 유니온 타입 step 은 CookingStep 과 DeliveryStep 이 가질수 있는 값을 모두 가지고 있는 타입이라고 볼 수 있다.
즉, CookingStep 타입을 가진 값 과 DeliveryStep 타입을 가진 모든 값들의 집합이라고 볼 수 있다.
step = {
orderId: "1";
price: 1000;
},
{
orderId: "2";
price: 4000;
},
{
orderId: "1",;
time: 12;
distance: "1km";
},
{
orderId: "2",;
time: 15;
distance: "10km";
},
// ...
기존 타입을 합쳐 필요한 모든 기능을 가진 하나의 타입을 만드는 것.
앞서 유니온 타입은 합집합의 개념이라고 설명했다. 교차 타입은 교집합의 개념과 비슷하다.
예를 들어, 다음과 같이 A 와 B 의 교차 타입인 MyIntersectoin 이 있다. MyIntersectoin 타입의 모든 값은 A 타입의 값이며, B 타입의 값이다. 집합 관점에서 보면 집합 MyIntersectoin 의 모든 원소는 A 와 집합 의 원소 이자 B 의 모든 원소라는 뜻이다.
그러므로 교차 타입은 유니온 타입 과 달리, 각 타입의 모든 속성을 가진 단일 타입이 된다.
따라서, 위의 예제를 교차 타입으로 바꾸어 보면 다음과 같이, 교차 타입 step 은 distance, time, price 와 같은 속성을 포함하고 있다는 것을 알 수 있다.
interface CookingStep {
orderId: string;
price: number;
}
interface DeliveryStep {
orderId: string;
time: number;
distance: string;
}
function getDeliveryDistance(step: CookingStep & DeliveryStep) {
return step.distance;
// WORK!
}
다른 예시를 살펴보자. 앞서 유니온 타입에서 설명했던 속성의 집합 이 아닌 값의 집합 으로 이해해야할 필요성은 다음 예시를 통해서도 알 수 있다.
interface DeliveryTip {
tip : string;
}
interface StarRating {
rate : number;
}
type Filter = DeliveryTip & StarRating;
const filter:Filter = {
tip : "1000원 이하",
rate: 4
}
속성의 집합 의 관점에서 보면, Filter 의 타입은 공집합(never 타입) 이 되어야한다. DeliveryTip 와 StarRating 은 공통된 속성이 없기 때문이다. 하지만 Filter 타입은 DeliveryTip 과 StarRating 속성을 모두 포함한 타입이 된다. 왜냐하면 타입이 속성이 아닌 값의 집합으로 해석되기 때문이다.
그렇기에 Filter 는 두 타입을 모두 만족하는 값의 타입이 된다.
다음 예제를 살펴보자.
type IdType = string | number;
type Numeric = number | boolean;
type Univeral = IdType & Numeric;
먼저 Universal 타입을 다음과 같이 4가지로 생각해 볼 수 있다.
string 이면서 number 인 경우string 이면서 boolean 인 경우number 이면서 number 인 경우number 이면서 boolean 인 경우교차 타입은 두 타입을 모두 만족하는 값의 타입이므로, 3 번만 유효하게 되므로 Universal 타입은 number 가 된다.
4.1.1. 에서 사용한 extends 키워드를 사용해서 교차 타입을 작성할 수 도 있다.
interface BaseMenuItem {
itemName : string | null;
itemImageUrl : string | null;
stock : number | null;
}
interface BaseCartItem extends BaseMenuItem {
quantity : number;
}
이를 교차 타입의 관점에서 작성하면 다음과 같다. 덧붙여서, extends 키워드를 사용할 때 interface 를 사용한것 과 달리, 교차 타입을 생성할 때는 type 을 사용한다. 이유는 유니온 타입 혹은 교차 타입을 사용한 새로운 타입은 오직 type 키워드 로만 선언할 수 있기 때문이다.
type BaseMenuItem = {
itemName : string | null;
itemImageUrl : string | null;
stock : number | null;
}
type BaseCartItem = {
quantity : number;
} & BaseMenuItem;
주의할 점은 extends 키워드를 사용한 타입이 교차타입과 100% 상응하지 않는 다는 것이다. 다음 2개의 예시에서 extends 다른 점을 확인할 수 있다.
다음 예시에서는 extends 를 사용하여 Filter 타입을 정의하였다. 이 경우, tip 의 타입이 호환되지 않는 다는 에러가 발생한다.
interface DeliveryTip {
tip : number;
}
interface Filter extends DeliveryTip {
tip : string;
}
// ERROR!
이를 교차 타입으로 작성해보자.
type DeliveryTip = {
tip : number;
}
type Filter = {
tip : string;
} & DeliveryTip
// WORK!
교차 타입으로 작성하면 에러가 발생하지 않는다. type 키워드는 교차 타입으로 선언되었을 때 새롭게 추가되는 속성에 대해 미리 알 수 없기 때문에 선언 시 에러가 발생하지는 않는다. 하지만 tip 이라는 같은 속성에 대해 서로 호환되지 않는 타입(number 와 string) 이 선언되어 결국 never 타입이 된다.
TS 에서 타입 좁히기 는 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정을 말한다. 타입 좁히기를 통해 더 정확하고 명시적인 타입 추론을 할 수 있게 되고, 복잡한 타입을 작은 범위로 축소하여 타입 안정성을 높일 수 있다.
조건문과 타입 가드를 활용하여 변수 또는 표현식 의 타입 범위를 좁혀 다양한 상황에 따라 다른 동작을 수행하는 것을 말한다.
예를 들어, 어떤 함수가 A | B 타입의 매개변수 를 받는다고 가정해보자. 인자 타입이 A 또는 B 일 때를 구분해서 로직을 처리하고 싶다면 어떻게 해야 할까? if 문을 사용해서 처리하면 될 것 같지만, 컴파일 시 타입 정보는 모두 제거되어 런타임 에 존재하지 않기 때문에 타입을 사용하여 조건을 만들수 는 없다. 즉, 컴파일 해도 타입 정보가 사라지지 않는 방법을 사용해야 한다.
특정 문맥 안에서 TS 가 해당 변수를 타입 A 로 추론하도록 유도 하면서 런타임 에서도 유효한 방법이 필요한데, 이때 타입가드를 사용하면 된다.
타입 가드 는 런타임 에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능을 말한다. 타입 가드는 크게 JS 연산자를 사용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있다.
JS 연산자를 활용한 타입 가드 는 typeof, instanceof, in 과 같은 연산자를 사용해서 제어문으로 특정 타입 값을 가질 수 밖에 없는 상황을 유도하여 자연스럽게 타입을 좁히는 방식이다. JS 연산자를 사용하는 이유는 런타임에 유효한 타입 가드를 만들기 위해서다.
사용자 정의 타입가드 는 사용자가 직접 어떤 타입으로 값을 좁힐지를 직접 지정하는 방식이다. 그럼 다음 항목에서 어떤 상황에서 어떤 종류의 타입 가드를 활용할 수 있을지 살펴보자.
typeof 연산자를 활용하면 원시 타입에 대해 추론할 수 있다. typeof A 형태로 사용하며, typeof A === B 를 조건으로 분기 처리하면, 해당 분기 내에서는 A 의 타입이 B 로 추론된다.
const isString:(word: string | number) {
// 이 분기에서는 word의 타입이 string 으로 추론된다
if (typeof word === "string") {
return true;
}
return false;
}
다만 typeof 는 JS 타입 시스템만 대응할 수 있다. 이유는 JS 의 동작 방식으로 인해 null 과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기엔 한계가 있다.
따라서 typeof 연산자는 주로 원시 타입을 좁히는 용도로만 사용할 것을 권장한다. 아래는 typeof 연산자를 사용하여 검사할 수 있는 타입 목록이다.
instanceof 는 인스턴스화된 객체 타입을 판별하는 타입 가드로 사용할 수 있다. A instanceof B 형태로 사용하며 A 에는 타입을 검사할 대상 변수, B 에는 특정 객체의 생성자가 들어간다.
const onKeyDown = (event: React.keyboardEvent) => {
if (event.target instanceof HTMLInputElement && event.key === "Enter") {
// 이 분기에서는 event.target 의 타입이 HTMLInputElement 이며,
// event.key 가 'Enter' 이다.
event.target.blur();
onCTAButtonClick(event);
}
}
instanceof 는 A 의 프로토타입 체인에 생성자 B 가 존재하는지를 검사하여, 존재한다면 true, 그렇지 않다면 false 를 반환한다. 이러한 동작 방식으로 인해 A 의 프로토타입 속성 변화에 따라 instanceof 연산자의 결과가 달라질 수 있다는 점은 유의해야 한다.
in 연산자는 객체에 속성이 있는지 확인한 다음에 true 또는 false 를 반환한다. A in B 의 형태로 사용하며, 이름 그대로 A 라는 속성이 B 객체에 존재하는 지를 검사한다. 프로토타입 체인으로 접근할 수 있는 속성이면 전부 true 를 반환한다.
주의할 점은, B 객체 내부에 A 라는 속성이 있는 지 없는지만 검사하는 것이기 때문에, 만약 A 속성에 undefined 를 할당한다고 해서, A in B 가 false 를 반환하는 것은 아니다. delete 연산자를 사용하여 객체 내부에서 해당 속성을 제거해야만 false 를 반환한다.
다음 예제 에서, 두 객체 타입을 cookieKey 속성을 가졌는지 아닌지에 따라 in 연산자로 조건을 만들 수 있다.
interface BasicNoticeProps {
noticeTitle: string;
noticeBody: string;
}
interface NoticeWithCookieProps extends BasicNoticeProps {
cookieKey: string;
noForADay?: boolean;
}
type NoticeProps = BasicNoticeProps | NoticeWithCookieProps ;
const Notice: React.FC<NoticeProps> = (props) => {
if ("cookieKey" in props) {
return <NoticeWithCookie {...props} />
}
return <BasicNotice {...props} />
}
JS 의 in 연산자는 런타임의 값만을 검사하지만, TS 에서는 객체 타입에 속성이 존재하는 지를 검사한다. 따라서, 위의 예제에서, if 문 스코프에서 TS 는 props 객체를 cookieKey 속성을 갖는 객체 타입인 NoticeWithCookieProps 로 해석한다. 이처럼 여러 객체 타입을 유니온 타입으로 가지고 있을 때 in 연산자를 사용해서 속성의 유무에 따라 조건 분기를 할 수 있다.
직접 타입 가드 함수를 만들 수도 있다. 이러한 방식의 타입 가드는 반환 타입이 타입 명제인 함수를 정의하여 사용할 수 있다.
타입 명제는 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 특별한 형태의 함수 이다.
A is B형식으로 작성하며,A는 매개변수 이름이고B는 타입이다.
참/거짓의 진릿값을 반환하면서 반환 타입을 타입 명제로 지정하게 되면 반환 값이 참일 때 A 매개변수의 타입을 B 타입으로 취급하게 된다. 아래 예제로 확인해보자.
const isDestinationCode = (x: string): x is DestinationCode =>
destinationCodeList.includes(x);
isDestinationCode 는 string 타입의 매개변수가 destinationCodeList 배열의 원소 중 하나인지를 검사하여 boolean 을 반환하는 함수이다. 해당 함수의 반환 값의 실제 타입은 boolean 이지만, 반환 값을 x is DestinationCode 로 타이핑하여 TS 에게 이 함수가 사용되는 곳의 타입을 추론할 때, 해당 조건을 타입 가드로 사용하도록 알려준다.
isDestinationCode 함수를 사용하는 예시를 보며 반환 값의 타입이 boolean 인 것과 is 를 활용한 것과의 차이를 알아보자.
const getAvailableDestinationNameList = async (): Promise<DestinationName[]> => {
const data = await AxiosRequest<string[]>("get", ".../destinations");
const destinationNames: DestinationName[] = [];
data?.forEach((str) => {
// if 문 내 isDestinationCode 함수로 data 의 str 이
// destinationCodeList 의 원소인지 체크
if (isDestinationCode(str)) {
// 맞다면 destinationNames 배열에 push
destinationNames.push(DestinationNameSet[str]);
});
return destinationNames;
};
만일 isDestinationCode 의 반환값 타이핑을 is 가 아닌, boolean 으로 지정했다면 TS 는 이를 어떻게 추론할까?
우선 개발자는 if 문 내 str 의 타입이 DestinationCode 라는 것을 알 수 있다. isDestinationCode 함수 내부에서 실행되는 str 을 판별하는 방식인 includes 함수 를 해석할 수 있기 때문이다.
하지만 TS 는 isDestinationCode 내부에 있는 includes 를 해석하여 타입 추론을 할 수 없다. TS 는 if문 스코프의 str 을 string 으로만 추론한다. data 의 타입은 string[] 이기 때문이다.
결과적으로, string 타입인 str 을 DestinationName[] 타입인 destinationNames 배열에 push 할 수 없다는 에러가 발생한다.
그렇기에 is 를 사용하여, TS 에게 isDestinationCode 함수의 반환값의 타입을 x is DestinationCode 이라고 알려줌으로써, str 의 타입을 한번 더 좁혀 추론할 수 있게 할 수 있다.
종종 태그 된 유니온 으로도 불리는 식별할 수 있는 유니온 은 타입 좁히기에 널리 사용 되는 방식이다. 이 절에서는 예시를 살펴보며 식별할 수 있는 유니온 을 어떨 때 사용할 수 있는지와 장점을 알아보겠다.
필요한 값을 사용자가 올바르게 입력했는지 확인하는 유효성 검사 기능이 있다고 치자. 이때, 다양한 방식의 에러를 보여주는데, 이를 크게 텍스트 에러, 토스트 에러, 얼럿 에러로 분류한다. 이들 모두 유효성 에러로 에러 코드와 에러 메세지를 가지고 있으며, 에러 노출 방식에 따라 추가로 필요한 정보가 있을 수 있다.
각 에러 타입을 다음과 같이 정의했다고 해보자.
type TextError = {
errorCode: string;
errorMessage: string;
}
type ToastError = {
errorCode: string;
errorMessage: string;
toastShowDuration: number; // 토스트를 띄워줄 시간
}
type AlertError = {
errorCode: string;
errorMessage: string;
onConfirm: () => void; // 얼럿 창의 확인 버튼을 누른 뒤 액션
}
위의 에러 타입들을 유니온 타입을 원소로 하는 배열을 정의해보면 다음과 같을 것이다.
type ErrorFeedbackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
{ errorcode: "100", errorMessage: "텍스트 에러"},
{ errorcode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000},
{ errorcode: "300", errorMessage: "얼럿 에러", onConfirm: () => {}},
]
TextError, ToastError, AlertError 의 유니온 타입인 ErrorFeedbackType 의 원소를 갖는 배열 errorArr 를 정의함으로써 다양한 에러 객체를 관리할 수 있게 되었다. 여기서 해당 배열에 각각 에러 타입별로 정의한 필드를 가지는 에러 객체가 포함되길 원한다고 해보자. 즉, ToastError 의 toastShowDuration 필드와 AlertError 의 onConfirm 필드를 동시에 모두 가지는 객체에 대해서는 타입 에러를 뱉어야 할 것이다.
const errorArr: ErrorFeedbackType[] = [
// ...
{
errorCode: "999",
errorMessage: "잘못된 에러",
toastShowDuration: 3000,
onConfirm: () => {},
},
]
하지만 위 코드를 작성했을 때 JS 는 덕 타이핑 언어이기 때문에 별도의 타입 에러를 뱉지 않는 것을 확인할 수 있다.
덕타이핑은 객체의 타입을 그 객체의 실제 속성과 메서드에 따라 결정하는 방식 을 뜻한다. 객체가 특정 타입이나 클래스에 명시적으로 속하지 않아도, 해당 타입이 요구하는 속성과 메서드를 모두 가지고 있으면 그 타입으로 간주할 수 있다.
이런 상황에서 타입 에러가 발생하지 않는 다면 의미가 불명확한 무수한 에러 객체가 생겨날 위험성이 커진다.
따라서 에러 타입을 구분할 방법이 필요하다. 각 타입이 비슷한 구조를 가지지만 서로 호환되지 않도록 만들어 주기위해선 타입들이 서로 포함 관계를 가지지 않도록 정의해야한다. 이때 적용할 수 있는 방식이 식별할 수 있는 유니온(Discriminated Unions) 을 활용하는 것이다.
식별할 수 있는 유니온이란 타입 간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자(태그라고도 부른다.) 를 달아 포함 관계를 제거하는 것
판별자의 개념으로 errorType 이라는 필드를 새로 정의해보자. 각 에러 타입마다 이 필드에 대해 다른 값을 가지도록 하여 판별자를 달아주면 이들은 포함 관계를 벗어나게 된다.
type TextError = {
errorType: "TEXT";
errorCode: string;
errorMessage: string;
}
type ToastError = {
errorType: "TOAST";
errorCode: string;
errorMessage: string;
toastShowDuration: number; // 토스트를 띄워줄 시간
}
type AlertError = {
errorType: "ALERT";
errorCode: string;
errorMessage: string;
onConfirm: () => void; // 얼럿 창의 확인 버튼을 누른 뒤 액션
}
위와 같이 에러 객체를 새롭게 정의한 상태에서 errorArr 을 새로 정의해보자.
const errorArr: ErrorFeedbackType[] = [
// ...
{
errorType: "TEXT",
errorCode: "999",
errorMessage: "잘못된 에러",
toastShowDuration: 3000,
//ERROR!
onConfirm: () => {},
},
{
errorType: "TOAST",
errorCode: "210",
errorMessage: "토스트 에러",
onConfirm: () => {},
//ERROR!
},
]
정확하지 않은 에러 객체에 대해 타입 에러가 발생하는 것을 확인 할 수 있다.
식별할 수 있는 유니온 을 사용할 때 주의할 점이 있다. 식별할 수 있는 유니온 의 판별자는 유닛 타입(unit type) 으로 선언되어야 정상적으로 동작한다.
유닛 타입은 다른 타입으로 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입을 말한다.
null,undefined, 리터럴 타입을 비롯해true, 1 등 정확한 값을 나타내는 타입이 유닛 타입에 해당한다. 반면, 다양한 타입을 할당 할 수 있는void,string,number와 같은 타입은 유닛 타입으로 적용되지 않는다.
Typescript 공식 깃헙 이슈 탭을 살펴보면 식별할 수 있는 유니온 의 판별자로 사용할 수 있는 타입을 다음과 같이 정의하고 있다.
다음 예시로 확인해보자.
interface A {
value: "a"; // unit type
answer: 1;
}
interface B {
value: string; // not unit type
answer: 2;
}
interface C {
value: Error; // not unit type (instantiable type)
answer: 3;
}
type Unions = A | B | C;
function handle (param: Unions) {
/** 판별자가 value일 때 */
param.answer; // 1 | 2 | 3
// 'a'가 리터럴 타입이므로 타입이 좁혀진다.
// 단, 이는 string 타입에 포함되므로 param은 A 또는 B 타입으로 좁혀진다
if (param.value === "a") {
param.answer; // 1 | 2 return;
}
// 유닛 타입이 아니거나 인스턴스화할 수 있는 타입일 경우 타입이 좁혀지지 않는다
if (typeof param.value === "string") {
param.answer; // 1 | 2 | 3 return;
}
if (param.value instanceof Error) {
param.answer; // 1 | 2 | 3 return;
}
/* 판별자가 answer일 때 */
param.value; // string | Error
// 판별자가 유닛 타입이므로 타입이 좁혀진다
if (param.answer === 1) {
param.value; // 'a'
}
}
위 코드에서 판별자가 value 일 때 판별자로 선정한 값 중 ‘a’ 만 유일하게 유닛타입이다. 그러므로 param.value === “a” 인 경우에만 유일하게 타입이 좁혀지는 것을 볼 수 있다.
판별자가 answer 일 때를 생각해보면 모두 유닛 타입 (1, 2, 3) 이므로 타입이 정상적으로 좁혀진다.
Exhaustiveness 는 사전적으로 철저함, 완전함을 의미한다. 따라서
Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말하며 타입 좁히기에 사용되는 패러다임 중 하나다.
필요하다고 생각되는 부분만 분기 처리를 하는 경우도 있지만, 때로는 모든 케이스에 대해 분기 처리를 해야만 유지보수 측면에서 안전하다고 생각되는 상황이 생긴다. 이때 Exhaustiveness Checking 을 통해 모든 케이스에 대한 타입 검사를 강제할 수 있다.
예시를 보며 Exhaustiveness Checking 의 의미를 이해해보자.
다양한 상품권을 가격에 따라 상품권 이름을 반환해주는 함수를 작성한다고 치자.
type ProductPrice = "10000" | "20000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "상품권 1만 원";
if (productPrice === "20000") return "상품권 2만 원";
else {
return "상품권";
}
}
여기서 새로운 상품권이 생겨 ProductPrice 타입이 업데이트되어야 한다고 해보자.
type ProductPrice = "10000" | "20000" | "50000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "상품권 1만 원";
if (productPrice === "20000") return "상품권 2만 원";
if (productPrice === "50000") return "상품권 5만 원"; // 조건 추가 필요
else {
return "상품권";
}
}
이처럼 ProductPrice 타입이 업데이트되었을 때 getProductName 함수도 함께 업데이트되어야 한다. 그러나 getProductName 함수를 수정하지 않아도 별도 에러가 발생하는 것이 아니기 때문에, 실수할 여지가 있다.
이와 같이 모든 타입에 대한 타입 검사를 강제하고 싶다면 다음과 같이 코드를 작성하면 된다.
type ProductPrice = "10000" | "20000" | "50000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "상품권 1만 원";
if (productPrice === "20000") return "상품권 2만 원";
// if (productPrice === "50000") return "상품권 5만 원"; // 조건 추가 필요
else {
exhaustiveCheck(productPrice); // Error:Argument of type 'string' is not assign
// able to parameter of type 'never'
return "상품권";
}
}
const exhaustiveCheck = (param: never) => {
throw new Error("type error!");
}
위의 코드를 살펴보면 productPrice 가 “50000” 일 때의 분기 처리는 주석 처리했고, 대신 exhaustiveCheck(productPrice); 함수를 추가했다. 해당 부분에서 에러를 뱉고있는 것을 확인 할 수 있다.
exhaustiveCheck 함수를 자세히 살펴보면, never 타입의 매개변수를 받고 있다. 즉, 매개변수로 그 어떤 값도 받을 수 없으며 만일 값이 들어온다면 에러를 내뱉는다. 이렇게 모든 케이스에 대한 타입 분기 처리를 철저하게 해주지 않았을 때, 컴파일타임 에러가 발생하게 하는 것을 Exhaustiveness Checking 이라고 하며, 위 코드에서는 exhaustiveCheck 함수가 그 역할을 하고 있다. 이 함수를 타입 처리 조건문의 마지막 else 문에 사용하면 앞의 조건문에서 모든 타입에 대한 분기 처리를 강제할 수 있다.
이렇게 Exhaustiveness Checking 을 활용하면 예상치 못한 런타임 에러를 방지하거나 요구사항이 변경되었을 때 생길 수 있는 위험성을 줄일 수 있다.