4장. 타입 확장하기 & 좁히기
타입 확장하기
기본적으로 타입스크립트에서는 interface와 type 키워드를 사용해서 타입을 정의하고 extends, 교차 타입, 유니온 타입을 사용하여 타입을 확장한다.
-
타입 확장의 장점
-
코드 중복을 줄임
-
명시적인 코드 작성(어떤 타입을 확장한 코드인지)
-
높은 확장성
interface 키워드의 경우 확장 시
extends
를, type 키워드의 경우&
를 이용해 확장 -
-
유니온 타입
- 2개 이상의 타입을 조합하여 사용하는 방법
- 합집합의 개념 (A | B)
- 주의 : 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근 가능
interface CookingStep { orderId: string; price: number; } interface DeliveryStep { orderId: string; time: number; distance: string; } function getDeliveryDistance(step: CookingStep | DeliveryStep) { return step.distance; // Property ‘distance’ does not exist on type ‘CookingStep | DeliveryStep’ // Property ‘distance’ does not exist on type ‘CookingStep’ }
-
교차 타입
- 기존의 타입을 합쳐 필요한 모든 기능을 가진 하나의 타입을 만드는 것
- 교집합의 개념 (A & B)
- 그러나 A와 B가 공통된 속성이 없는데도 교차 타입은 A와 B의 타입은 공집합이 아닌, 해당 속성을 모두 포함한 타입이 됨.
- 타입이 속성이 아닌 값의 집합으로 해석되기 때문!
- 교차 타입을 사용할 때 타입이 서로 호환되지 않는 경우도 있음. (아래 코드)
type IdType = string | number; type Numeric = number | boolean; type Universal = IdType & Numeric; /** * Universal의 타입은 * 1. string이면서 number인 경우 * 2. string이면서 boolean인 경우 * 3. number이면서 number인 경우 * 4. number이면서 boolean인 경우 * => 두 타입을 모두 만족하는 경우에만 유지되기 때문에 1,2,4는 성립되지 않고 3번만 유효함 * => Universal의 타입은 number */
-
extends와 교차 타입
- 유니온 타입과 교차 타입을 사용한 새로운 타입은 오직 type 키워드로만 선언할 수 있음
- 주의 : extends 키워드를 사용한 타입이 교차 타입과 100% 상응하지는 않음
interface DeliveryTip { tip: number; } interface Filter extends DeliveryTip { tip: string; // Interface ‘Filter’ incorrectly extends interface ‘DeliveryTip’ // Types of property ‘tip’ are incompatible // Type ‘string’ is not assignable to type ‘number’ } // => tip의 타입이 호환되지 않음
- 같은 예시를 교차 타입으로 작성할 경우, extends를 &로 바꿨을 뿐인데 에러가 발생하지 않음
type DeliveryTip = { tip: number; }; type Filter = DeliveryTip & { tip: string; };
- 이때 tip의 속성 타입은 never!
- type 키워드는 교차 타입으로 선언되었을 때 새롭게 추가되는 속성에 대해 미리 알 수 없기 때문에 선언 시 에러가 발생하지 않음
- 하지만 tip이라는 같은 속성에 대해 서로 호환되지 않는 타입이 선언되어 결국 never 타입이 된 것
-
배달의민족 메뉴 시스템에 타입 확장 적용하기
/** * 방법1 타입 내에서 속성 추가 * 기존 Menu 인터페이스에 추가된 정보를 전부 추가 */ interface Menu { name: string; image: string; gif?: string; // 요구 사항 1. 특정 메뉴를 길게 누르면 gif 파일이 재생되어야 한다 text?: string; // 요구 사항 2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다 } /** * 방법2 타입 확장 활용 * 기존 Menu 인터페이스는 유지한 채, 각 요구 사항에 따른 별도 타입을 만들어 확장시키는 구조 */ interface Menu { name: string; image: string; } /** * gif를 활용한 메뉴 타입 * Menu 인터페이스를 확장해서 반드시 gif 값을 갖도록 만든 타입 */ interface SpecialMenu extends Menu { gif: string; // 요구 사항 1. 특정 메뉴를 길게 누르면 gif 파일이 재생되어야 한다 } /** * 별도의 텍스트를 활용한 메뉴 타입 * Menu 인터페이스를 확장해서 반드시 text 값을 갖도록 만든 타입 */ interface PackageMenu extends Menu { text: string; // 요구 사항 2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다 }
- [방법 1] 하나의 타입에 여러 속성을 추가할 때
menuList: Menu[] // OK specialMenuList: Menu[] // OK packageMenuList: Menu[] // OK specialMenuList.map((menu) => menu.text); // TypeError: Cannot read properties of undefined
-
[방법 2] 타입을 확장하는 방식
- 이를 활용해 각 배열의 타입을 확장할 타입에 맞게 명확히 규정할 수 있음
menuList: Menu[] // OK specialMenuList: Menu[] // NOT OK specialMenuList: SpecialMenu[] // OK packageMenuList: Menu[] // NOT OK packageMenuList: PackageMenu[] // OK specialMenuList.map((menu) => menu.text); // Property ‘text’ does not exist on type ‘SpecialMenu’
- 때문에 주어진 타입에 무분별하게 속성을 추가하여 사용하는 것보다 타입을 확장해서 사용하는 것이 좋음
- 적절한 네이밍을 사용해 타입의 의도를 명확히 표현할 수도 있고, 코드 작성 단계에서 예기치 못한 버그도 예방할 수 있기 때문
타입 좁히기 - 타입 가드
타입 좁히기 : 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정. 이를 통해 더 정확하고 명시적인 타입 추론이 가능하고, 복잡한 타입을 작은 범위로 축소하여 타입 안정성을 높임
-
타입 가드에 따라 분기 처리하기
타입스크립트에서의 분기 처리 : 조건문과 타입 가드를 활용하여 변수나 표현식의 타입 범위를 좁혀 다양한 상황에 따라 다른 동작을 수행하는 것
타입 가드 : 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능
- 특정 문맥 안에서 타입스크립트가 해당 변수를 타입 A로 추론하도록 유도하면서 런타임에서도 유효한 방법이 필요한데, 이때 타입 가드를 사용하면 됨
- 타입 가드는 크게 자바스크립트 연산자를 사용한 타입 가드(typeof, instanceof, in)와 사용자 정의 타입 가드로 구분 가능
- 자바스크립트 연산자를 사용하는 이유는 런타임에 유효한 타입 가드를 만들기 위해서임
- 사용자 정의 타입 가드 : 직접 어떤 타입으로 값을 좁힐지 직접 지정하는 방식
-
원시 타입을 추론할 때: typeof 연산자 활용하기
- typeof는 자바스크립트 타입 시스템만 대응할 수 있음
- 자바스크립트 동작 방식으로 인해 null과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기에는 한계가 있음
⇒ typeof 연산자는 주로 원시 타입을 좁히는 용도로만 사용할 것을 권장
- 검사할 수 있는 타입 목록 : string, number, boolean, undefined, object, function, bigint, symbol
const replaceHyphen: (date: string | Date) => string | Date = (date) => { if (typeof date === “string”) { // 이 분기에서는 date의 타입이 string으로 추론된다 return date.replace(/-/g, “/”); } return date; };
-
인스턴스화된 객체 타입을 판별할 때: instanceof 연산자 활용하기
- A instanceof B 형태로 사용하며, A에는 타입을 검사할 대상 변수, B에는 특정 객체의 생성자가 들어감
- instanceof는 A의 프로토타입 체인에 생성자 B가 존재하는지를 검사해서 존재한다면 true, 그렇지 않다면 false를 반환
- 이러한 동작 방식으로 인해 A의 프로토타입 속성 변화에 따라 instanceof 연산자의 결과가 달라질 수 있다는 점은 유의해야 함
interface Range { start: Date; end: Date; } interface DatePickerProps { selectedDates?: Date | Range; } const DatePicker = ({ selectedDates }: DatePickerProps) => { const [selected, setSelected] = useState(convertToRange(selectedDates)); // ... }; export function convertToRange(selected?: Date | Range): Range | undefined { return selected instanceof Date ? { start: selected, end: selected } : selected; }
-
객체의 속성이 있는지 없는지에 따른 구분: in 연산자 활용하기
-
객체의 속성이 있는지 없는지에 따라 객체 타입 구분 가능
-
A in B의 형태로 사용하는데, 이름 그대로 A라는 속성이 B 객체에 존재하는지를 검사
-
프로토타입 체인으로 접근할 수 있는 속성이면 전부 true를 반환
-
in 연산자는 B 객체 내부에 A 속성이 있는지 없는지를 검사하는 것이기 때문에 B 객체에 존재하는 A 속성에 undefined를 할당한다고 해서 false를 반환하는 것은 아님
-
delete 연산자를 사용하여 객체 내부에서 해당 속성을 제거해야만 false를 반환
-
자바스크립트의 in 연산자는 런타임의 값만을 검사하지만 타입스크립트에서는 객체 타입에 속성이 존재하는지를 검사
-
아래 코드에서 얼리 리턴했기 때문에 if문 스코프 밖에 위치하는 return문의 props 객체는 BasicNoticeDialogProps 타입으로 해석됨
얼리 리턴 : 특정 조건에 부합하지 않으면 바로 반환하는 것
interface BasicNoticeDialogProps { noticeTitle: string; noticeBody: string; } interface NoticeDialogWithCookieProps extends BasicNoticeDialogProps { cookieKey: string; noForADay?: boolean; neverAgain?: boolean; } export type NoticeDialogProps = | BasicNoticeDialogProps | NoticeDialogWithCookieProps; const NoticeDialog: React.FC<NoticeDialogProps> = (props) => { if (“cookieKey” in props) return <NoticeDialogWithCookie {...props} />; return <NoticeDialogBase {...props} />; };
⇒ 여러 객체 타입을 유니온 타입으로 가지고 있을 때 in 연산자를 사용해서 속성의 유무에 따라 조건 분기 가능
-
-
is 연산자로 사용자 정의 타입 가드 만들어 활용하기
-
직접 타입 가드 함수를 만들 수 있음
-
이러한 방식의 타입 가드는 반환 타입이 타입 명제인 함수를 정의하여 사용할 수 있음
-
타입 명제는 A is B 형식으로 작성하면 되는데, 여기서 A는 매개변수 이름이고 B는 타입임
-
참/거짓의 진릿값을 반환하면서 반환 타입을 타입 명제로 지정하게 되면 반환 값이 참일 때 A 매개변수의 타입을 B 타입으로 취급하게 됨
타입 명제 : 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 특별한 형태의 함수
// string 타입의 매개변수가 destinationCodeList 배열의 원소 중 하나인지를 검사하여 boolean을 반환하는 함수 const isDestinationCode = (x: string): x is DestinationCode => destinationCodeList.includes(x);
- 함수의 반환 값을 boolean이 아닌 x is DestinationCode으로 타이핑하여 타입스크립트에게 이 함수가 사용되는 곳의 타입을 추론할 때 해당 조건을 타입 가드로 사용하도록 알려줌
const getAvailableDestinationNameList = async (): Promise<DestinationName[]> => { const data = await AxiosRequest<string[]>(“get”, “.../destinations”); const destinationNames: DestinationName[] = []; data?.forEach((str) => { if (isDestinationCode(str)) { destinationNames.push(DestinationNameSet[str]); /* isDestinationCode의 반환 값에 is를 사용하지 않고 boolean이라고 한다면 다음 에러가 발생한다 - Element implicitly has an ‘any’ type because expression of type ‘string’ can’t be used to index type ‘Record<”MESSAGE_PLATFORM” | “COUPON_PLATFORM” | “BRAZE”, “통합메시지플랫폼” | “쿠폰대장간” | “braze”>’ */ } }); return destinationNames; };
- 만약 isDestinationCode의 반환 값 타이핑을 x is DestinationCode가 아닌 boolean으로 했다면 타입스크립트는 isDestinationCode 함수 내부에 있는 includes 함수를 해석해 타입 추론을 할 수 없음
- 타입스크립트는 if문 스코프의 str 타입을 좁히지 못하고 string으로만 추론함
- destinationNames의 타입은 DestinationName[]이기 때문에 string 타입의 str을 push할 수 없다는 에러가 발생
⇒ 타입스크립트에 반환 값에 대한 타입 정보를 알려주고 싶을 때 is를 사용할 수 있음
-
타입 좁히기 - 식별할 수 있는 유니온(Discriminated Unions)
종종 태그된 유니온(Tagged Union)으로도 불림
-
에러 정의하기
- 필요한 값을 사용자가 올바르게 입력했는지를 확인하는 유효성 검사 진행
- 유효성 에러 발생 시 크게 텍스트 에러, 토스트 에러, 얼럿 에러로 분류해 다양한 방식으로 에러를 보여줌
- 유효성 에러는 에러 코드와 에러 메시지를 가지고 있으며, 노출 방식에 따라 추가로 필요한 정보가 있을 수 있음
- 각 에러 타입을 아래와 같이 정의했다고 해보자
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: () => {} }, ];
- 여기서 해당 배열에 에러 타입별로 정의한 필드를 가지는 에러 객체가 포함되길 원한다고 해보자.
- 즉, ToastError의 toastShowDuration 필드와 AlertError의 onConfirm 필드를 모두 가지는 객체에 대해서는 타입 에러를 뱉어야 할 것
const errorArr: ErrorFeedbackType[] = [ // ... { errorCode: “999”, errorMessage: “잘못된 에러”, toastShowDuration: 3000, onConfirm: () => {}, }, // expected error ];
- 그러나 자바스크립트는 덕 타이핑 언어이기 때문에 별도의 타입 에러를 뱉지 않음
- 이러한 상황에서 타입 에러가 발생하지 않는다면, 앞으로 무수한 에러 객체가 생겨날 위험성이 커짐
-
식별할 수 있는 유니온
-
따라서 에러 타입을 구분할 방법이 필요함
-
각 타입이 비슷한 구조를 가지지만 서로 호환되지 않도록 만들어주기 위해서는 타입들이 서로 포함 관계를 가지지 않도록 정의해야 함
-
이때 적용할 수 있는 방식이 바로 식별할 수 있는 유니온을 활용하는 것
식별할 수 있는 유니온 : 타입 간의 구조 호환을 막기 위해 타입마나 구분할 수 있는 판별자를 달아 주어 포함 관계를 제거하는 것
- 아래 코드에서는 판별자의 개념으로 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; }; 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: “999”, errorMessage: “잘못된 에러”, toastShowDuration: 3000, // Object literal may only specify known properties, and ‘toastShowDuration’ does not exist in type ‘TextError’ onConfirm: () => {}, }, { errorType: “TOAST”, errorCode: “210”, errorMessage: “토스트 에러”, onConfirm: () => {}, // Object literal may only specify known properties, and ‘onConfirm’ does not exist in type ‘ToastError’ }, { errorType: “ALERT”, errorCode: “310”, errorMessage: “얼럿 에러”, toastShowDuration: 5000, // Object literal may only specify known properties, and ‘toastShowDuration’ does not exist in type ‘AlertError’ }, ];
-
-
식별할 수 있는 유니온의 판별자 선정
-
주의 : 식별할 수 있는 유니온의 판별자는 유닛 타입으로 선언되어야 정상적으로 동작함
유닛 타입 : 다른 타입으로 쪼개지지 않는 오직 하나의 정확한 값을 가지는 타입
-
null, undefined, 리터럴 타입을 비롯해 true, 1 등 정확한 값을 나타내는 타입이 유닛 타입에 해당됨
-
반면 다양한 타입을 할당할 수 있는 void, string, number와 같은 타입은 유닛 타입으로 적용되지 않음
유니온의 판별자로 사용할 수 있는 타입
- 리터럴 타입이어야 한다.
- 판별자로 선정한 값에 적어도 하나 이상의 유닛 타입이 포함되어야 하며, 인스턴스화 할 수 있는 타입은 포함되지 않아야 한다.
interface A { value: “a”; // unit type answer: 1; } interface B { value: string; // not unit type answer: 2; } interface C { value: Error; // 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’ } }
-
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” | “5000”; const getProductName = (productPrice: ProductPrice): string => { if (productPrice === “10000”) return “배민상품권 1만 원”; if (productPrice === “20000”) return “배민상품권 2만 원”; if (productPrice === “5000”) return “배민상품권 5천 원”; // 조건 추가 필요 else { return “배민상품권”; } };
- 이처럼 ProductPrice 타입이 업데이트되었을 때 getProductName 함수도 함께 업데이트되어야 함
- ProductPrice가 “5000”일 경우의 조건도 검사하여 의도한대로 상품권 이름을 반환해야 함
- 그러나 getProductName 함수를 수정하지 않아도 별도 에러가 발생하는 것이 아니기 때문에 실수할 여지가 있음
- 이와 같이 모든 타입에 대한 타입 검사를 강제하고 싶다면 다음과 같이 코드를 작성하면 됨
type ProductPrice = “10000” | “20000” | “5000”; const getProductName = (productPrice: ProductPrice): string => { if (productPrice === “10000”) return “배민상품권 1만 원”; if (productPrice === “20000”) return “배민상품권 2만 원”; // if (productPrice === “5000”) return “배민상품권 5천 원”; else { exhaustiveCheck(productPrice); // Error: Argument of type ‘string’ is not assignable to parameter of type ‘never’ return “배민상품권”; } }; const exhaustiveCheck = (param: never) => { throw new Error(“type error!”); };
- 앞의 코드에서 ProductPrice가 “5000”일 때의 분기 처리가 주석 처리된 것이 보일 것임
- 그리고 exhaustiveCheck(productPrice);에서 에러를 뱉고 있는데 ProductPrice 타입 중 5000이라는 값에 대한 분기 처리를 하지 않아서(철저하게 검사하지 않았기 때문에) 발생한 것
- 이렇게 모든 케이스에 대한 타입 분기를 처리를 해주지 않았을 때, 컴파일타임 에러가 발생하게 하는 것을 Exhaustiveness Checking이라고 함
- Exhaustiveness Checking을 활용하면 예상치 못한 런타임 에러를 방지하거나 요구사항이 변경되었을 때 생길 수 있는 위험성을 줄일 수 있음
⇒ 타입에 대한 철저한 분기 처리가 필요하다면 Exhaustiveness Checking 패턴 활용해보기