타입 확장의 가장 큰 장점은 코드의 중복을 줄일 수 있다는 것. 타입을 새로 만들기 보단 기존 타입을 확장함으로써 불필요한 코드를 줄일 수 있다.
// 메뉴 요소 타입 interface interface BaseMenuItem { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; // 메뉴 타입에 수량 정보 추가 (extends) interface BaseCartItem extends BaseMenuItem { quantity: number; } // 메뉴 요소 타입 type type BaseMenuItem = { itemName: string | null; itemImageUrl; string | null; itemDiscountAmount: number; stock: number/ null; }; // 메뉴 타입에 수량 정보 추가 type BaseCartItem = { quantity: number; } & BaseMenuItem;
위 예시에서처럼 Base-MenuItem에 있는 속성을 중복해서 작성하지 않고 확장(extends BaseMenuItem)을 활용하여 타입을 정의함으로써 중복된 코드를 줄일 수 있다. 그뿐만 아니라 BaseCartItem이 BaseMenuItem에서 확장되었다는 것을 쉽게 확인할 수 있는 것처럼 더 명시적인 코드를 작성할 수 있게 된다.
타입 확장은 중복 제거, 명시적인 코드 작성 외에도 확장성이란 장점을 가지고 있다. 앞에서 정의한 BaseCartItem을 활용하면 요구 사항이 늘어날 때마다 새로운 CartItem 타입을 확장 하여 정의할 수 있다.
/** * 수정할 수 있는 장바구니 요소 타입 * 품절 여부, 수정할 수 있는 옵션 배열 정보가 추가되었다 * */ interface EditableCartItem extends BaseCartItem { isSoldOut: boolean; optionGroups: Selectable0ptionGroup[]; } /** * 이벤트 장바구니 요소 타입 * 주문 가능 여부에 대한 정보가 추가되었다 * */ interface EventCartItem extends BaseCartItem { orderable: boolean; }
이 코드에서 BaseCartItem을 확장하여 만든 EditabLeCartItem, EventCartItem 타입을 볼 수 있다. 이렇게 타입 확장을 활용하면 관련된 요구 사항이 생길 때마다 필요 한 타입을 손쉽게 만들 수 있다. 더욱이, 기존 요소에 대한 요구 사항이 변경되어도 BaseCartItem 타입만 수정하고 EditabLeCartItem이나 EventCartItem은 수정하지 않아도 된다.
type MyUnion = A | B;
유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근가능
interface CookingStep { orderId: string; price: number; } interface DeliveryStep { orderId: string; time: number; distance: string; } function getDeliveryDistance(step: CookingStep | DeliveryStep) { return step.distance }
getDeliveryDistance 함수는 CookingStep과 DeliveryStep의 유니온 타입 값을 step 이라는 인자로 받고 있지만, 함수 본문에서 step.distance를 호출하고 있는데 distance 는 DeliveryStep에만 존재하는 속성이기 때문에 에러가 발생한다.
interface CookingStep { orderId: string; time: number; price: number; } interface DeliveryStep { orderId: string; time: number; distance: string; } type BaedalProgress = CookingStep & DeliveryStep;
여기서 유니온 타입과 다른 점이 있다. BaedaLProgress는 CookingStep과 DeliveryStep 타입을 합쳐 모든 속성을 가진 단일 타입이 된다.
// BaedalProgress 타입의 progress 값은 // CookingStep이 갖고 있는 price 속성과 // Deliverystep이 갖고 있는 distance 속성을 포함하고 있다. function logBaedalInfo(progress: BaedalProgress) { console.log(주문 금액: Sfprogress.price}'); console.log(배달 거리: Sprogress.distance}'); }
type MyIntersection = A & B;
유니온 타입은 합집합의 개념이고, 교차 타입은 교집합의 개념과 비슷하다.
MyIntersection 타입의 모든 값은 A 타입의 값이며, MyIntersection 타입의 모든 값은 B 타입의 값이다.
/* 배달 팁 */ interface DeliveryTip { tip: string; } /* 별점 */ interface StarRating { rate: number; } /* 주문 필터 */ // Filter는 DeliveryTip의 tip 속성과 StarRating의 rate 속성을 모두 만족하는 값이 된다. type Filter = DeliveryTip & StarRating; const filter: Filter = { tip: 1000원 이하", rate: 4, }
교차 타입을 사용할 때 타입이 서로 호환되지 않는 경우도 있다
// 아래의 Universal 타입은 number 일때만 유효하기 때문에 Universal의 타입은 number이다. type IdType = string | number; type Numeric = number | boolean; type Universal = IdType & Numeric;
interface BaseMenuItem { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; } interface BaseCartItem extends BaseMenuItem { quantity: number; }
BaseCartItem은 BaseMenultem을 확장함으로써 BaseMenultem의 속성을 모두 포함하고 있다. 즉, BaseCartItem는 BaseMenultem의 속성을 모두 포함하는 상위 집합이 되고 BaseMenuItem는 BaseCartItem의 부분집합이 된다. 이를 교차 타입의 관점에서 작성하면 다음과 같다.
//유니온 타입과 교차 타입을 사용 한 새로운 타입은 오직 type 키워드로만 선언할 수 있다. type BaseMenuItem = { itemName: string | null; itemImageUrl: string | null; itemDiscountAmount: number; stock: number | null; } type BaseCartItem = { quantity: number; } & BaseMenultem; const baseCartItem: BaseCartItem = { itemName: "지은이네 떡볶이", itemImageUrl: "https://www.woowahan.com/images/jieun-tteokbokkio.png", itemDiscountAmount: 2000, stock: 100, quantity: 2, };
extends 키워드를 사용한 타입이 교차 타입과 100% 상응하지는 않는다는 것이다. 아래 예시를 보자.
// interface는 호환되지 않는 타입이 선언되면 에러가 발생한다. 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' } // type는 호환되지 않는 타입이 선언되면 never 타입이 된다. type DeliveryTip = { tip: number; } type Filter = DeliveryTip & { tip: string; }
배달의 메뉴 메뉴목록은 이미지와 메뉴명으로 이루어져 있다.

* 메뉴에 대한 타입 * 메뉴 이름과 메뉴 이미지에 대한 정보를 담고 있다 */ interface Menu { name: string; image: string; } function MainMenuO { // Menu 타입을 원소로 갖는 배열 const menuList: Menu[] = [{name: "1인분", image: "1인분.png"}, ..] return ( ‹ul> {menuList.map((menu) => ( <li> <img src=(menu-image} /> <span>menu-name/span> </li> ))} </ul> ) }
이때 특정 메뉴의 중요도를 다르게 주기 위한 요구 사항이 추가되었다고 가정해보자.
요구 사항을 만족하는 타입의 작성 방법을 2가지로 생각해볼 수 있다.
1. 하나의 타입에 여러 속성을 추가하는 방법
2. 타입을 확장하는 방법
/** * 방법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. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다 }
다음 처럼 3가지 종류의 메뉴 목록이 있을 때 각 방법을 적용해보자
/** * 각 배열은 서버에서 받아온 응답 값이라고 가정 */ const menulist = [ { name: "찜", image: "찜.png" }, { name: "찌개", image: "찌개.png" }, { name: "회", image: "회.png" }, ]; const specialMenuList = [ { name: "돈까스", image: "돈까스.png", gif: "돈까스.gif" }, { name : "피자", image: "피자.png", gif: 피자.gif" }, ]; const packageMenuList = [ { name: "1인분", image: 1인분.png", text: 1인 가구 맞춤형" }, { name:"족발", image: "족발.png", text: "오늘은 족발로 결정" }, ]
menulist:Menu[] specialMenuList:Menu[] packageMenuList:Menu[]
위 방법은 가지고 있지 않은 속성에도 접근이 가능하다 ex)specialMenuList에 text등
이때 에러가 발생한다.
menulist:Menu[] specialMenuList:Menu[] specialMenuList:SpecialMenu[] packageMenuList:Menu[] packageMenuList:PackageMenu[]
위 방법은 가지고 있지 않은 속성에도 접근이 불가능하기 때문에 타입이 잘못되었음을 미리 알 수 있다.
즉, 무분별하게 속성을 추가하는 것보다 타입을 확장해서 사용하는 것이 좋다.
typeof A === B 를 조건으로 분기처리 할 수 있다. typeof는 원시타입을 좁히는 용도로만 사용할 것을 권장한다.const replaceHyphen: (date: string | Date) => string | Date = (date) => { if (typeof date === "string") { // 이 분기에서는 date의 타입이 string으로 추론된다 return date.replace(/-/g, "/"); } return date; }
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; }
const onKeyDown = (event: React.KeyboardEvent) => { if (event.target instanceof HTMLInputElement && event.key === "Enter") { // 이 분기에서는 event.target의 타입이 HTMLInputELement이며 // event.key가 'Enter'이다 event.target.blur(); onCTAButtonClick(event); } }
A in B의 형태로 사용하며 이름 그대로 A라는 속성이 B 객체에 존재하는지를 검사한다. 프로토타입 체인으로 접근할 수 있는 속성이면 전부 true를 반환한다.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} />; };
직접 타입 가드 함수를 만들 수도 있다. 이러한 방식의 타입 가드는 반환 타입이 타입 명제인 함수를 정의하여 사용할 수 있다. 타입 명제는 A is B 형식으로 작성할 수 있으며, A는 매개변수 이름이고 B는 타입이다.
함수의 반환 값을 boolean이 아닌 x is DestinationCode로 타이핑하여 타입스크립트에게 이 함수가 사용되는 곳의 타입을 추론할 때 해당 조건을 타입 가드로 사용하도록 알려준다. isDestinationCode 함수를 사용하는 예시를 보면서 반환 값의 타입이 boolean인 것과 is를 활용한 것과의 차이를 알아보자.
// string 타입의 매개변수가 destinationCodeList 배열의 원소 중 하나인지를 검사하여 boolean을 반환하는 함수 const isDestinationCode = (x: string): x is DestinationCode => destinationCodeList.includes(x); const getAvailableDestinationNameList = async 0): Promise‹DestinationNameD> => { 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; }
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: () => {}, }, // 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; };
에러 객체를 다음과 같이 정의한 상태에서 errorArr을 새로 정의해보자
type ErrorFeedbackType = TextError | ToastError | AlertError; 4 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’ }, ];
위 코드 처럼 작성할경우 정확하지 않은 에러 객쳉 대해 타입에러가 발생하지 않는 것을 볼 수 있다.
식별할 수 있는 유니온을 사용할 때 주의할 점이 있다.
식별할 수있는 유니온의 판별자는 유닛 타입unit type으로 선언되어야 정상적으로 동작한다.
유닛 타입은 다른 타입으로 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입을 말한다.
공식 깃허브의 이슈 탭을 살펴보면 식별할 수 있는 유니온의 판별자로 사용할 수 있는 타입을 다음과 같이 정의하고 있다.
- 리터럴 타입이어야 한다. - 판별자로 선정한 값에 적어도 하나 이상의 유닛 타입이 포함되어야 하며, 인스턴스화할 수 있는 타입은 포함되지 않아야 한다.
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’ } }
위 코드에서는 a만이 유일한 유닛 타입이다.
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 “배민상품권”; } };
getProductName 함수를 수정하지 않아도 별도 에러가 발생하는 것이
아니기 때문에 실수할 여지가 있다.Exhaustiveness Checking을 사용하면 모든 타입에 대한 타입 검사를 강제 할 수 있다.
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!”); };
위에서 exhaustiveCheck라는 함수가 매개변수를 never 타입으로 선언하고 있다. 즉, 매개변수로 그 어떤 값도 받을 수 없으며 만일 값이 들어온다면 에러를 내뱉는다. 이 함수를 타입 처리 조건문의 마지막 else문에 사용하면 앞의 조건문에서 모든 타입에 대한 분기 처리를 강제할 수 있다.
이렇게 Exhaustiveness Checking을 활용하면 예상치 못한 런타임 에러를 방지하거나 요구사항이 변경되었을 때 생길 수 있는 위험성을 줄일 수 있다.