
- #typescript
타입스크립트 - 타입 확장, 좁히기
Prolip
2024-12-16
typeof, instance of, is, in 등 타입을 확장하고 좁히는 여러가지 방법 알아보기...
타입 확장
기존 타입을 사용해 새로운 타입을 정의하는 것을 말한다. 타입스크립트에서는 interface, type을 사용해 타입을 정의한다. 이렇게 정의된 타입을 extends, 교차 타입, 유니온 타입을 사용해 확장할 수 있다.
장점은?
타입을 확장해서 얻을 수 있는 가장 큰 장점은 코드 중복을 줄일 수 있다는 점이다.
interface BaseMenuItem { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; }
예를 들어 위의 BaseMenuItem을 확장해 장바구니에 들어있는 아이템 요소와 이벤트에 사용되는 아이템 요소에 대한 타입을 지정한다면, 아래와 같이 모든 요소를 포함하며 수량 정보와 주문 가능 여부를 추가한 타입을 정의할 수 있다.
interface BaseCartItem extends BaseMenuItem { quantity: number; } interface EventCartItem extends BaseMenuItem { orderable: boolean; }
또, 한 눈에 봐도 BaseMenuItem을 확장해 정의한 타입이라는 것도 확인할 수 있다. 타입 확장은 중복 제거, 명시적인 코드 작성 외 확장성이란 장점도 포함한다. 그렇기에 필요한 요구사항이 새로 생길 때마다 필요한 타입을 쉽게 정의할 수 있게 된다.
유니온 타입
유니온 타입을 사용해 타입을 확장하는 것도 가능하다. 하지만 유니온 타입으로 확장된 타입은 두 타입이 공통으로 가지는 속성만 접근할 수 있다.
interface CookingStep { orderId: string; price: number; } interface DeliveryStep { orderId: string; time: number; distance: string; } function getDeliveryDistance(step: CookingStep | DeliveryStep) { // return step.distance // Error return step.orderId; }
위 함수에선 공통 속성이 아닌 distance에 접근한다면 에러를 발생시킨다. 위 함수의 매개변수인 step이라는 유니온 타입은 CookingStep 또는 DeliveryStep 타입에 해당할 뿐, CookingStep이면서 DeliveryStep이라는 의미가 아니다.
교차 타입
교차 타입의 경우 유니온 타입과 반대로 두 타입을 모두 가진 단일 타입이 된다.
interface CookingStep { orderId: string; time: number; price: number; } interface DeliveryStep { orderId: string; time: number; distance: string; } type Progress = CookingStep & DeliveryStep; function getDeliveryDistance(step: Progress) { return [step.price, step.distance]; }
즉, Progress라는 교차타입은 CookingStep2이면서 DeliveryStep2이다.
또, 위에서 살펴본 extends 키워드를 사용해서 교차 타입을 작성할 수도 있다.
interface BaseMenuItem { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; } interface BaseCartItem extends BaseMenuItem { quantity: number; }
BaseCartItem은 BaseMenuItem을 확장했기 때문에 BaseMenuItem의 속성을 모두 가지고 있다. 이 타입을 교차 타입이 관점으로 바라보면, 아래와 같이 나타낼 수 있다.
type BaseMenuItem = { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; }; type BaseCartItem = { quantity: number } & BaseMenuItem;
타입 좁히기 - 타입 가드
타입스크립트에서 타입 좁히기를 통해 더 정확하고 명시적인 타입 추론이 가능하다. 구체적으로 어떤 상황에서 필요한지 설명해보자면, 어떤 함수의 인자로 A | B 타입의 인자를 받는다고 가정해보자.
그럼 A일 때와 B일 때의 분기처리를 하고 싶은데, 타입을 통해 분기처리를 한다면 컴파일 시 타입 정보가 모두 제거되어 런타임에 존재하지 않아 조건을 만들 수는 없다. 그렇기 때문에 컴파일 이후에도 타입 정보가 사라지지 않는 방법을 사용해야 하는 것이다.
이 때, 특정 문맥 안에서 타입스크립트가 해당 변수를 타입 A로 추론하도록 유도하면서 런타임에서도 동일하게 유효한 방법이 필요한데, 이 때 타입 가드를 사용하면 된다. 타입 가드는 크게 자바스크립트 연산자를 사용한 방법과 사용자 정의 타입 가드로 구분이 가능하다.
원시 타입을 추론할 때: typeof 연산자
원시 타입을 추론할 때에는 typeof 연산자를 활용한다. typeof A === B를 조건으로 분기 처리하면, 해당 분기 내에서 A의 타입이 B로 추론된다.
function isNumeric(n: number | string) { if (typeof n === "number") { console.log(`${n} is Numeric`); } else { console.log(`${n} is not Numeric`); } } isNumeric(5); isNumeric("5");
하지만 아래와 같이 자바스크립트의 동작 방식으로 인해 null이 object 타입으로 판별되는 등 한계가 있다.
if (typeof null === "object") { console.log("null is object?"); }
인스턴스화된 객체 타입을 판별할 때: instanceof 연산자
인스턴스화된 객체 타입을 판별할 때에는 instanceof 연산자를 활용한다. A instanceof B의 경우 A의 프로토타입 체인에 생성자 B가 존재하는지 검사해 true 혹은 false를 반환한다.
아래 함수의 경우 전달된 event 타겟이 HTMLInputElement이며, 눌린 키가 엔터일 경우 blur 메서드를 사용할 수 있도록 분기처리하고 있다.
const onKeyDown = (event: KeyboardEvent) => { if (event.target instanceof HTMLInputElement && event.key === "Enter") { event.target.blur(); } };
객체의 속성이 있는지 없는지에 따른 구분: in 연산자
객체의 속성이 있는지 확인하기 위해 in 연산자를 활용할 수 있다. in 연산자는 객체에 속성이 있는지 확인해 true 혹은 false를 반환한다. A in B 형태로 사용하며 A 속성이 B 객체에 존재하는지 검사한다.
interface BasicNoticeDialog { noticeTitle: string; noticeBody: string; } interface NoticeDialogWithCookieProps extends BasicNoticeDialog { cookieKey: string; noForADay?: boolean; neverAgain?: boolean; } type NoticeDialogProps = BasicNoticeDialog | NoticeDialogWithCookieProps; function RenderNoticeDialog(props: NoticeDialogProps) { if ("cookieKey" in props) { // ~~ } }
위와 같이 일반적인 다이얼로그 프롭과 쿠키 정보를 포함한 다이얼로그를 하나의 함수로 처리하고 싶다면 쿠키를 포함한 다이얼로그에 추가된 cookieKey가 객체에 있는지 판단해 분기처리할 수 있다.
is 연산자로 사용자 정의 타입 가드 만들기
위에서 살펴본 자바스크립트 연산자를 활용할 수도 있고, 직접 타입 가드 함수를 만들 수도 있다.
반환 타입이 타입 명제인 함수를 정의해 사용할 수 있는데, A is B 형태로 작성하면 된다.
type Food = string; const food: Food[] = ["햄버거", "초밥", "피자"]; // 함수의 반환 값을 x is Food로 타이핑한다. 이후 이 함수가 사용되는 곳의 타입을 추론할 때 이 조건을 타입 가드로 사용하도록 알려준다. function isFood(x: string): x is Food { return food.includes(x); } function getFoodList(): Food[] { const data: string[] = ["피자", "커피", "음료수", "덤벨"]; const foodList: Food[] = []; data.forEach((str) => { if (isFood(str)) { foodList.push(str); } }); return foodList; } console.log(getFoodList()); // [ "피자" ]
타입 좁히기 - 식별할 수 있는 유니온 (Discriminated Unions)
타입 좁히기에 널리 사용되는 방식으로, 예제를 통해 알아보도록 하자.
프로젝트에서 에러를 3가지 종류로 나누어 정의했다고 가정해보자. 예를 들어, 텍스트만을 표시하는 TextError, 토스트 형식으로 보여주는 ToastError, 팝업 형식으로 에러를 표시하는 AlertError..
그럼 아래와 같이 타입을 정의할 수 있을 것이다.
type TextError = { errorCode: string; errorMessage: string; }; type ToastError = { errorCode: string; errorMessage: string; toastShowDuration: number; // 얼마나 표시할지. }; type AlertError = { errorCode: string; errorMessage: string; onConfirm: () => void; // 확인 버튼을 누르고 어떤 동작을 수행할지. };
그럼 이제 종합적으로 에러를 보관하는 배열이 있을 때, 이 3가지 에러를 모두 담고 싶으면 아래와 같이 구현할 수 있을 것이다.
type ErrorFeedbackType = TextError | ToastError | AlertError; const errorArr: ErrorFeedbackType[] = [ { errorCode: "100", errorMessage: "텍스트 에러" }, { errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000 }, { errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {} }, ];
이렇게만 보면 문제가 없겠지만 아래의 경우를 보자.
const errorArr: ErrorFeedbackType[] = [ { errorCode: "100", errorMessage: "텍스트 에러" }, { errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000 }, { errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {} }, { errorCode: "400", errorMessage: "이건 무슨 에러일까?", toastShowDuration: 400000, onConfirm: () => {}, }, ];
모든 속성을 가진 에러는 잘못된 에러겠으나 자바스크립트는 덕 타이핑 언어이기 때문에 errorCode, errorMessage, toastShowDuration, onConfirm 속성을 모두 가진 가진 구조체가 에러 배열에 들어와도 에러가 발생하지 않을 것이다.
식별할 수 있는 유니온
위와 같은 상황에서 에러 타입을 구분할 수 있는 방법이 바로 식별할 수 있는 유니온을 활용하는 것이다. 이 식별할 수 있는 유니온은 타입 간 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자를 달아주는 것이다.
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; }; type ErrorFeedbackType = TextError | ToastError | AlertError; const errorArr: ErrorFeedbackType[] = [ { errorType: "TEXT", errorCode: "100", errorMessage: "텍스트 에러" }, { errorType: "TOAST", errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000, }, { errorType: "ALERT", errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {}, }, // { // errorType: "TEXT" // errorCode: "400", // errorMessage: "이건 무슨 에러일까?", // toastShowDuration: 400000, // X // onConfirm: () => {}, // X // }, Error ];
이제 위와 같이 errorType을 통해 포함 관계를 벗어나 마지막 에러의 경우 TextError에 없는 속성인 toastShowDuration, onConfirm 속성에 대해 에러를 표시하게 된다.
이 판별자는 유닛 타입으로 선언되어야만 하는데, 이 유닛 타입은 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입이다. 예를 들어, 만약 판별자로 errorType이라는 속성을 사용하는데 이 판별자가 string 타입으로 선언된다면, 문자열인 무언가가 올 수 있어 정확히 판별할 수 없게 된다.
Exhaustiveness Checking으로 정확한 타입 분기 유지
Exhaustiveness Checking이란 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말하며, 타입 좁히기에 사용되는 패러다임 중 하나다.
위에서 살펴본 타입 가드를 사용해 타입에 대한 분기 처리를 수행하면 필요하다고 생각되는 부분만 분기 처리해 요구 사항에 맞는 코드를 작성할 수 있게 된다.
하지만 만약 모든 케이스에 대해 분기 처리를 해야만 하는 상황이 생긴다면 어떡할까? 만약 커맨드를 입력 받아 캐릭터를 움직이는 함수가 있다고 가정해보자.
처음 요구사항으로는 UP, DOWN만 처리하고 있지만, 이후 LEFT, RIGHT에 대한 요구사항이 생겼을 때, 내부에서 분기처리를 깜빡한다면, 이는 런타임 환경에서 에러를 발생시킬 것이다. 이 때, 사용할 수 있는 방법이 Exhaustiveness Checking이며, 이를 통해 모든 케이스에 대한 타입 검사를 강제할 수 있게 된다.
예시를 보고 정확히 이해해보자.
type Directons = "UP" | "DOWN" | LEFT; function move(direction: Directons) { switch (direction) { case "UP": console.log("up!"); break; case "DOWN": console.log("down!"); break; default: const _exhaustive: never = direction; // Type '"LEFT"' is not assignable to type 'never'. throw new Error("unknown type error!"); } }
위와 같이 direction에 LEFT 타입이 추가되었을 때, 이 조건에 대한 분기처리를 해주지 않는다면, direction이 타고 내려와 할당할 수 없는 never 타입이 되어버려 에러를 발생시킨다. 하지만 추가된 타입에 대한 분기처리를 해주면 사실상 정말 내려올 수 없는 never 타입이기 때문에 에러가 발생하지 않는다.

타입스크립트 - 고급 타입 정리하기
