장바구니는 상품의 정보들을 모두 가지고 있어야 합니다. 하지만 해당 타입에서는 속성을 중복으로 작성하지 않고 확장(extends BaseMenuItem) 하여 타입을 정의하였습니다.
interface 대신 type을 쓴다면 다음과 같은 코드를 작성할 수 있습니다.
type BaseMenuItem = {
itemName: string | null;
itemImageUrl: string | null;
itemDiscountAmount : number;
stock: number | null;
};
type BaseCartItem = {
quantity: number;
} & BaseMenuItem;
타입 확장은 다음과 같은 장점들을 가지고 있습니다.
중복 제거
명시적인 코드 작성
확장성
만약 요구 사항이 늘어날 경우 새로운 CartItem 타입을 확장하여 정의 할 수 있습니다.
interface EditableCartItem extends BaseCartItem {
isSoldOut: boolean;
optionGroups: SelectableOptionGroup[];
};
/**
* 이벤트 장바구니 요소 타입
* 주문 가능 여부에 대한 정보가 추가되었습니다.
**/
interface EventCartItem extends BaseCartItem {
orderable: boolean;
}
이 코드에서는 BaseCartItem에서 확장하여 만든 EditableCartItem 와 EventCartItem 타입의 코드를 볼 수 있습니다. 이렇게 타입 확장을 활용하면 장바구니와 관련된 요구사항이 생길 때마다 필요한 타입을 손쉽게 만들 수 있습니다.
유니온 타입
유니온 타입은 두 개 이상의 타입을 조합하여 사용하는 방법입니다. 이는 합집합으로 해석할 수 있습니다.
type MyUnion = A | B;
interface CookingStep {
orderId: string;
price: number;
};
interface DeliveryStep {
orderId: string;
time: number;
distance: string;
};
function getDeliveyDistance(step: CookingStep | DeliveryStep) {
return step.distance;
// 🚨 property 'distance' does not exist on type 'CookingStep' | 'DeliveryStep'
// 🚨 property 'distance' does not exist on type 'CookingStep'
}
getDeliveyDistance() 함수는 인자로 CookingStep 또는 DeliveryStep 타입을 받고 있다.
함수 본문에서는 distance를 호출하고 있지마 이는 공통된 속성이 아닌 DeliveryStep의 타입에만 존재하는 속성이므로 에러가 발생한다.
즉, step 이라는 유니온 타입은 CookingStep 또는 DeliveryStep 타입에 해당할 뿐이지 두 타입은 아니다.
각기 다른 키워드로 타입을 선언을 해보았습니다. interface 키워드에서는 extends, type 키워드에서는 &를 사용하여 각각 타입을 확장을 하였습니다.
하지만 모든 부분에서 원하는 결과가 나오는 것은 아닙니다.
type DeliveryTip = {
tip: number;
}
type Filter = DeliveryTip & {
tip: string;
}
위 타입에서 Filter의 tip의 타입은 never로 선언이 됩니다. 이는 교차 타입으로 선언됐을 때 새롭게 추가되는 속성에 대해 미리 알 수 없기 때문에 선언시 에러가 발생하지 않습니다 때문에 never 타입이 된 것입니다.
interface도 비슷한 상황을 마주할 수 있습니다.
interface DeliveryTip {
tip: number;
}
interface Filter extends DeliveryTip {
tip: string;
// interface 'Filter' incorrectly extends interface 'DeliveryTip'
// Types of property 'tip' ar incompatible
// Type 'string' is not assignable to type 'number'
}
배달의 민족 메뉴 시스템에 타입 확장 적용하기
/**
* 메뉴에 대한 타입
* 메뉴 이름과 메뉴 이미지에 대한 정보를 담고 있다.
*/
interface Menu {
name: string;
image: string;
}
function MainMenu(){
// 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>
)
}
이때 특정 메뉴의 중요를 다르게 주기 위해 요구사항을 추가한다고 가정해보겠습니다.
특정 메뉴를 길게 누르면 .gif 파일이 재생되어야 한다.
특정 메뉴는 이미지 대신 별도의 텍스트로만 노출되어야 한다.
우리는 위 2개의 요구 사항에 맞는 타입을 다음과 같이 나타낼 수 있습니다.
/**
* 방법 1. 타입 내에서 속성 추가
* 기존 Menu 인터페이스에 추가된 정보를 전부 추가.
*/
interface Menu {
name: string;
image: string;
gif?: string;
text?: string;
}
/**
* 방법 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. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출 되어야 한다.
}
menuList: Menu[] // OK
specialMenuList: Menu[] // OK
pacakageMenuList: Menu[] // OK
[방법 2] 타입을 확장하는 방식
각 배열의 타입을 확장할 타입에 맞게 명확히 규정할 수 있습다.
menuList: Menu[] // OK
specialMenuList: Menu[] // NOT OK
specialMenuList: SpecialMenu[] // OK
packageMenuList: Menu[] // NOT OK
packageMenuList: PackageMenu[] // OK
타입 좁히기 - 타입가드
타입스크립트에서 타입 좁히기는 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정을 말합니다. 이를 통해 좀 더 정확하고 명시적인 타입 추론을 할 수 있게 되고 복잡한 타입을 작은 범위로 축소하여 타입 안정성을 높일 수 있습니다.
타입 가드에 따라 분기 처리하기
분기 처리란 조건문과 타입 가드를 활용하여 변수나 표현식의 타입 범위를 좁혀 다양한 상황에 따라 동작을 수행하는 것을 말합니다. 타입 가드는 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능을 합니다.
타입스크립트를 사용하다보면 여러 타입을 할당할 수 있는 스코프에서 특정 타입으로 조건으로 만들어 분기 처리하고 싶을 때가 있습니다. 여러 타입을 할당할 수 있다는 것은 변수가 유니온 타입 또는 any타입 등 여러가지 타입을 받을 수 있다는 것을 말합니다.
예를 들어 어떤 함수가 A | B 타입의 매개변수를 받는다고 가정하였을 때 인자타입이 A 또는 B 타입일 때를 구분해서 로직을 처리하고 싶다면 어떻게 해야할까?..
if 문을 사용해서 처리하면 될 것 같지만 컴파일시에 타입 정보는 모두 제거되어 존재하지 않기 때문에 타입을 사용하여 조건을 만들 수는 없습니다.
때문에 특정 문맥 안에서 타입스크립트가 해당 변수를 타입 A로 추론하도록 유도하면서 런타임에서도 유효한 방법이 필요한데 이때 타입 가드를 활용하면 됩니다.
타입 가드
타입 가드는 크게 자바스크립트 연산자를 사용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있습니다.
연산자를 활용한 타입 가드는 typeof, instanceof, in 과 같은 연산자를 사용하여 제어문(if)등을 활용하여 특정 타입 값을 갖도록 유도하여 타입을 좁혀가는 형식입니다.
원시 타입 추론하기
typeof 연산자를 활용하면 원시 타입에 대해 추론할 수 있습니다. typeof A === B를 조건으로 분기 처리를 한다면, 해당 분기 내에서는 A 의 타입이 B로 추론됩니다. 다만 typeof는 자바스크립트의 타입 시스템만 대응할 수 있습니다. 때문에 null과 배열, object 타입으로 판별되는 등 복잡한 타입을 검증하기에는 한계가 있다.
따라서 typeof 연산자는 주로 원시 타입을 좁히는 용도로만 사용할 것을 권장합니다.
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;
}
인스턴스화된 객체를 판별할 때 instanceof 연산자 활용하기
typeof와 달리 instanceof 연산자는 인스턴스화된 객체 타입을 판별하는 타입 가드로 사용할 수 있습니다. A instanceof B 형태로 사용하며 A에는 타입을 검사할 대상 변수, B에는 특정 객체의 생성자가 들어갑니다.
instanceof는 A의 프로토타입 체인에 생성자 B가 존재하는지를 검사하는 연산자입니다. 존재한다면 true 그렇지 않다면 false를 반환합니다.
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;
}
이러한 동작 방식으로 인해 A의 프로토타입 속성 변화에 따라 instanceof 연산자의 결과가 달라질 수 있다는 점은 유의해야 합니다.
in 연산자는 런타임 시의 값만 검사하지만 객체 타입에 속성이 존재하는지를 검사합니다. if문 스코프에서 타입스크립트는 props 객체를 cookieKey 속성을 갖는 객체 타입인 NoticeDialogWithCookieProps로 해석합니다. 또한 얼리 리턴을 했기 때문에 if 스코프 밖에 위치하는 return 문의 props 객체는 BasicNoticeDia-logProps 타입으로 해석합니다.
여러 객체 타입을 유니온 타입으로 가지고 있을 때 in 연산자를 사용해서 속성의 유무에 따라 조건 분기를 할 수 있습니다.
is 연산자로 사용자 정의 타입 가드 만들어 활용하기
직접 타입 가드 함수를 만들어 사용할 수도 있습니다. 이러한 방식의 타입 가드는 반환 타입이 타입 명제인 함수를 정의하여 만들 수 있습니다.
타입 명제는 A is B 형식으로 A는 매개변수 이름 B는 타입을 나타냅니다.
const isDestinationCode = (x: string) : x is DestinationCode =>
destinationCodeList.includes(x);
이는 x가 destinationCodeList의 배열의 원소중 하나인지 판단하여 boolean 값을 반환하는 함수입니다. 반환 값을 boolean 값이 아닌 x is DestinationCode를 사용하면서 사용하는 곳의 타입을 추론할 때 해당 조건을 타입 가드로 사용하도록 알려줍니다.
유니온 타입인 ErrorFeedbackType을 통해 errorArr를 정의함으로써 다양한 에러 객체를 관리할 수 있게 되었습니다. 여기서 해당 배열에 에러 타입별로 정의한 필드를 가지는 에러 객체가 포함되길 원한다고 해보면, ToastError의 toastShowDuration 필드와 AlertError의 onConfrim 필드를 모두 가지는 객체에 대해서는 타입 에러를 뱉어야 할 것입니다.
저희 가 처음에 기대했던 대로 정확하지 않은 에러 객체에 대해 타입 에러가 발생하는 것을 확인할 수 있습니다.
식별할 수 있는 유니온의 판별자 선정
식별할 수 있는 유니온을 사용할 때 주의할 점이 있습니다. 판별자는 유닛 타입으로 선언되어야 정상적으로 동작합니다.
유닛 타입은 다른 타입으로 쪼개지지 않고 오직 하나의 정확한 값을 가지는 탕비을 말합니다. 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) {
params.answer; // 1 | 2 | 3
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'만 유일하게 유닛 타입인 것을 알 수 있습니다. 이때만 유닛 타입을 포함하고 있기 때문에 타입이 좁혀지는 것을 볼 수 있습니다.
판별자가 answer일 때를 생각해보면 판별자가 모두 유닛 타입이므로 타입이 정상적으로 좁혀집니다.
Exhaustiveness Checking으로 정확한 타입 분기 유지하기
타입 가드를 사용해서 타입에 대한 분기 처리를 수행하면 필요하다고 생각되는 부분만 분기 처리를 하여 요구 사항에 맞는 코드를 작성할 수 있게 됩니다. 하지만 때로는 모든 케이스에 대해 분기 처리를 해야만 유지보수 측면에서 안전하다고 생각되는 상황이 생깁니다.
예시를 통해 의미를 이해해봅니다.
상품권 배달의 민족 선물하기 서비스에는 다양한 상품권이 있습니다. 상품권 가격에 따라 상품권 이름을 반환해주는 함수를 작성하면 다음과 같습니다.
type ProductPrice = "10000" | "20000" | "5000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "배민상품권 1만 원";
if (productPrice === "20000") return "배민상품권 2만 원";
else {
return "배민 상품권";
};
};
하지만 새로운 상품권이 생겨서 해당 로직을 업데이트를 한다고 가장하여 보겠습니다.
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...
return "배민상품권";
}
};
const exhaustiveCheck = (param: never) => {
throw new Error("type error!");
};
앞의 코드에서 productPrice가 "5000"일 때의 분기처리를 하지 않아서 exhaustiveCheck이 에러를 뱉고 있습니다. 이는 모든 케이스에 대해 타입 분기 처리를 해주지 않았을 때, 컴파일타임 에러가 발생하게 하는 것을 Exhaustiveness Checking이라고 합니다.
해당 Exhaustiveness Checking을 활용하면 런타임 에러를 방지하거나 요구사항이 변경되었을 떄 생길 수 있는 위험성을 줄일 수 있습니다.