Part4_Extends,Narrowing
이효경
책 읽기

4장 타입 확장하기/좁히기

4.1 타입 확장하기

  • 타입 확장은 기존 타입을 사용해서 새로운 타입을 정의하는 것을 말한다.
  • 기본적으로 타입스크립트에서는 interface와 type 키워드를 통해 타입을 정의하고, extends, 교차타입, 유니온타입을 사용해서 타입을 확장한다.

4.1.1. 타입 확장의 장점

  • 가장 큰 장점은 코드 중복을 줄일 수 있다는 것
  • 아래 예시는 메뉴 타입을 기준으로 타입을 확장하여 장바구니 요소 타입을 정의했다.
/**
 * 메뉴 요소 타입
 * 메뉴 이름, 이미지, 할인율, 재고 정보를 담고 있다.
 **/
interface BaseMenuItem {
  itemName: string | null;
  itemImageUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
}
 
/**
 * 장바구니 요소 타입
 * 메뉴 타입에 수량 정보가 추가되었다
 **/
interface BaseCartItem extends BaseMenuItem {
  quantity: number;
}
  • interface 키워드 대신 type을 쓴다면 아래와 같이 코드를 작성하면 된다.
type BaseMenuItem = {
  itemName: string | null;
  itemImageUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
};
 
type BaseCartItem = {
  quantity: number;
} & BaseMenuItem;
  • 타입 확장은 중복 제거, 명시적 코드 작성 외에 확장성이란 장점을 가지고 있다.
  • 아래 코드에서 BaseCartItem을 확장해서 만든 EditableCartItem, EventCartItem 타입을 볼 수 있다. 이렇게 타입을 확장을 활용하면 관련된 요구사항이 생길 때마다 필요한 타입을 손쉽게 만들수 있고 기존 장바구니 요소에 대한 요구 사항이 변경되어도 BaseCartItem만 수정하면 되기 때문에 효율적이다.
/**
 * 수정할 수 있는 장바구니 요소 타입
 * 품절 여부, 수정할 수 있는 옵션 배열 정보가 추가되었다.
 **/
interface EditableCartItem extends BaseCartItem {
  isSoldOut: boolean;
  optionGroups: SelectableOptionGroup[];
}
 
/**
 * 이벤트 장바구니 요소 타입
 * 주문 가능 여부에 대한 정보가 추가되었다
 **/
interface EventCartItem extends BaseCartItem {
  orderable: boolean;
}

4.1.2. 유니온 타입

  • 유니온 타입에서 주의해야될 점은 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.
  • 아래 예시와 같이 step의 타입에서 distance는 공통으로 갖고 있지 않기 때문에 오류가 발생한다.
interface CookingStep {
  orderId: string;
  price: number;
}
 
interface DeliveryStep {
  orderId: string;
  time: number;
  distance: string;
}
 
function getDeliveryDistance(step: CookingStep | DelivertStep) {
  return step.distance; // error
}

4.1.3. 교차 타입

  • 교차 타입은 선언된 타입을 합쳐 모든 속성을 가진 단일 타입이 된다.
  • 따라서 아래 예시처럼 BaedalProgress 타입의 progress 값은 CookingStep이 갖고 있는 price 속성과 DeliveryStep이 갖고 있는 distance 속성을 포함하고 있다.
interface CookingStep {
  orderId: string;
  price: number;
}
 
interface DeliveryStep {
  orderId: string;
  time: number;
  distance: string;
}
 
type BaedalProgress = CookingStep & DeliveryStep;
 
function logBaedalInfo(progress: BadalProgress) {
  console.log(`주문금액: ${progress.price}`);
  console.log(`배달거리: ${progress.distance}`);
}
  • 교차 타입을 사용할 때 타입이 서로 호환되지 않는 경우도 있다.
  • 아래 Universal 타입은 IdType과 Numeric의 교차 타입이므로 두 타입을 모두 만족하는 경우에만 유지된다. 따라서 Universal의 타입은 number가 된다.
type IdType = string | number;
type Numeric = number | boolean;
type Universal = IdType & Numeric;

4.1.4. extends와 교차 타입

  • extends 키워드를 통해 교차 타입을 작성할 수도 있다.
  • 아래는 BaseCartItem은 quantity라는 새로운 속성와 BaseMenuItem의 모든 속성을 가진 단일 타입이다.
type BaseMenuItem = {
  itemName: string | null;
  itemImageUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
};
 
// 유니온 타입과 교차 타입은 type키워드로만 선언할 수 있다.
type BaseCartItem = {
  quantity: number;
} & BaseMenuItem;
  • 주의할 점은 extends 키워드를 사용한 타입이 교차 타입과 100% 상응하지는 않는다는 것이다.
  • DeliveryTip을 extends로 확장한 Filter 타입에 string 타입의 속성 tip을 선언하면 tip의 타입이 호환되지 않는다는 에러가 발생한다.
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의 타입은 never이다.
  • type 키워드는 교차 타입으로 선언되었을 때 새롭게 추가되는 속성에 대해 미리 알 수 없기 때문에 선언 시 에러가 발생하지 않는다. 하지만 tip이라는 같은 속성에 대해 서로 호환되지 않는 타입이 선언되어 결국 never 타입이 된 것이다.
  • ** 확장이 될 가능성이 있는 경우, 정적 타이핑을 위해서는 interface를 쓰는게 좋겠다.**
type DeliveryTip = {
  tip: number;
};
 
type Filter = DeliveryTip & {
  tip: string;
};

4.1.5. 배달의민족 메뉴 시스템에 타입 확장 적용하기

  • 생략

4.2 타입 좁히기 - 타입 가드

  • 타입스크립트에서 타입 좁히기는 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정을 말한다.
  • 타입 좁히기를 통해 더 정확하고 명시적인 타입 추론을 할 수 있게 되고 복잡한 타입을 작은 범위로 축소하여 타입 안정성을 높일 수 있다.

4.2.1. 타입 가드에 따라 분기 처리하기

  • 타입스크립트에서의 분기처리는 조건문과 타입 가드를 활용하여 변수나 표현식의 타입 범위를 좁혀 다양한 상황에 따라 다른 동작을 수행하는 것을 말한다.
  • 타입 가드는 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능을 말한다.
  • 예를 들어 어떤 함수가 A | B 타입의 매개변수를 받는다고 가정해보자. 인자 타입이 A 또는 B일 때를 구분해서 로직을 처리하고 싶다면 어떻게 해야 할까?
  • if문은 사용하면 컴파일 시 타입 정보는 모두 제거되어 조건을 만들 수 없다.
  • 특정 문맥 안에서 타입스크립트가 해당 변수를 타입 A로 추론하도록 유도하면서 런타임에서도 유효한 방법이 필요한데, 이 때 타입가드를 사용하면 된다. 타입 가드는 자바스크립트 연산자를 사용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있다.
  • 자바스크립트 연산자를 활용한 타입 가드는 typeof, instanceof, in과 같은 연산자를 사용해서 제어문으로 특정 타입 값을 가질 수밖에 없는 상황을 유도하여 자연스럽게 타입을 좁히는 방식이다.
  • 사용자 정의 타입 가드는 사용자가 직접 어떤 타입으로 값을 좁힐지를 지정하는 방식이다.

4.2.2. 원시 타입을 추론할 때:typeof 연산자 활용하기

  • typeof A === B를 조건으로 분기 처리하면, 해당 분기 내에서는 A의 타입이 B로 추론된다.
  • 다만 typeof는 자바스크립트 타입 시스템만 대응할 수 있다. 자바스크립트의 동작 방식으로 null과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기에는 한계가 있다.
  • 따라서 typeof 연산자는 주로 원시 타입을 좁히는 용도로만 사용할 것으로 권장한다.
  • typeof 연산자를 통해 검사할 수 있는 타입 : string, number, boolean, undefined, object, funtion, bigint, symbol
const replaceHyphen: (date: string | Date) => string | Date = (date) => {
  if (typeof date === "string") {
    return date.replace(/-/g, "/");
  }
 
  return date;
};

4.2.3 인스턴스화된 객체 타입을 판별할 때: 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;
 
  • 위 예시는 selected 매개변수가 Date인지를 검사한 후에 Range 타입의 객체를 반환할 수 있도록 분기 처리하고 있다.
  • typeof 연산자는 주로 원시타입을 판별하는데 사용한다면, instanceof 연산자는 인스턴스된 객체 타입을 판별하는 타입 가드로 사용할 수 있다.
  • A instanceof B 는 A의 프로토타입 체인에 생성자B가 존재하는지를 검사해서 존재한다면 true, 그렇지 않다면 false를 반환한다.
  • 이러한 동작 방식으로 인해 A의 프로토타입 속성 변화에 따라 instanceof 연산자의 결과가 달라질 수 있다는 점을 유의해야 한다.
const onKeyDown = (event: React.KeyboardEvent) => {
  if (event.target instanceof HTMLInputElement && event.key === "Enter") {
    event.target.blur();
    onCTAButtonClick(event);
  • 위 예시는 HTMLInputElement에 존재하는 blur 메서드를 사용하기 위해서 event.targe이 HTMLInputElement의 인스턴스인지를 검사한 후 분기 처리하는 로직이 나타나 있다.

4.2.4. 객체의 속성이 있는지 없는지에 따른 구분:in 연산자 활용하기

  • in 연산자는 true 또는 false를 반환한다. in 연산자를 사용하면 속성이 있는지 없는지에 따라 객체 타입을 구분할 수 있다.
  • 프로토타입 체인으로 접근할 수 있는 속성이면 전부 true를 반환한다. in 연산자는 B 객체 내부에 A 속성이 있는지 없는지를 검사하는 것이기 때문에 B객체에 존재하는 A 속성에 undefined를 할당한다고 해서 false를 반환하는 것은 아니다. delete 연산자를 사용하여 객체 내부에서 해당 속성을 제거해야만 false를 반환한다.
interface BasicNoticeDialongProps {
  noticeTitle: string;
  noticeBody: string;
}
 
interface NoticeDialogWithCookieProps extends BasicNoticeDialongProps {
  cookiekey: string;
  noForADay?: boolean;
  neverAgain?: boolean;
}
 
export type NoticeDialogProps =
  | BasicNoticeDialogProps
  | NoticeDialogWithCookiesProps;
 
const NoticeDialog: React.FC<NoticeDialogProps> = (props) => {
  if ("cookieKey" in props) return <NoticeDialogWithCookie {...props} />;
  return <NoticeDialogBase {...props} />;
};
  • 위 예시는 NoticeDialog 컴포넌트가 props로 받는 객체 타입이 BasicNoticeDialongProps인지 NoticeDialogWithCookieProps인지에 따라 렌더링하는 컴포넌트가 달라진다.
  • 위의 상황처럼 여러 객체 타입을 유니온 타입으로 가지고 있을 때 in연산자를 사용해서 속성의 유무에 따라 조건 분기를 할 수 있다.

4.2.5. is 연산자로 사용자 정의 타입 가드 만들어 활용하기

  • 반환 타입이 타입 명제인 함수를 정의하여 직접 타입 가드 함수를 만들 수 있다. 타입 명제는 A is B 형식으로 작성하면 되는데 A는 매개변수이고 B는 타입이다. 참/거짓의 진릿값을 반환하면서 반환 타입을 타입 명제로 지정하게 되면 반환 값이 참일 때 A 매개변수의 타입을 B 타입으로 취급하게 된다.
const isDestinationCode = (x: string): x is DestinationCode =>
  destinationCodeList.includes(x);
  • 위 예시는 isDestinationCode는 string 타입의 매개변수가 destinationCodeList 배열 원소 중 하나인지를 검사하여 boolean을 반환하는 함수이다.

4.3 타입 좁히기-식별할 수 있는 유니온

4.3.1. 에러 정의하기

  • 유효성 에러가 발생하면 사용자에게 다양한 방식으로 에러를 보여주는데 우아한형제들에서는 이 에러를 크게 텍스트 에러, 토스트 에러, 얼럿 에러로 분류한다. 이들 모두 유효성 에러로 에러 코드와 에러 메세지를 가지고 있으며, 에러 노출 방식에 따라 추가로 필요한 정보가 있을 수 있다.
type TextError = {
  errorCode: string;
  errorMsg: string;
};
type ToastError = {
  errorCode: string;
  errorMsg: string;
  toastShowDuration: number; // 토스트를 띄워줄 시간
};
type AlertError = {
  errorCode: string;
  errorMsg: string;
  onConfirm: () => void; // 얼럿 창의 확인 버튼을 누른 뒤 액션
};
  • 이 에러 타입의 유니온 타입을 원소로 하는 배열을 정의해보면 다음과 같다.
type ErrorFeedBackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
  { errorCode: "100", errorMsg: "텍스트 에러" },
  { errorCode: "200", errorMsg: "토스트 에러", toastShowDuraiton: 3000 },
  { errorCode: "300", errorMsg: "얼럿 에러", onConfirm: () => {} },
];
  • 위 예시에서 해당 배열에 에러 타입별로 정의한 필드를 가지는 에러 객체가 포함되길 원한다고 해보자. 즉, ToastError의 toastShowDuration 필드와 AlertError의 onConfirm 필드를 모두 가지는 객체에 대해서는 타입 에러를 뱉어야 할 것이다.
const errorArr: ErrorFeedbackType[] = [
  // ...
  {
    errorCode: "999",
    errorMsg: "잘못된 에러",
    toastShowDuration: 3000,
    onConfirm: () => {},
  }, // expected error
];
  • 하지만 이 코드를 작성했을 때 자바스크립트는 덕 타이핑 언어이기 때문에 별도의 타입 에러는 발생하지 않는다. 이렇게 되면 앞으로 의미를 알 수 없는 무수한 에러 객체가 생겨날 위험성이 커진다.

4.3.2. 식별할 수 있는 유니온

  • 따라서 에러 타입을 구분할 방법이 필요하다. 각 타입이 비슷한 구조를 가지지만 서로 호환되지 않도록 만들어주기 위해서는 타입들이 서로 포함 관계를 가지지 않도록 정의해야 한다. 이때 적용할 수 있는 방식으로 식별할 수 있는 유니온을 활용하는 것이다.
  • 식별할 수 있는 유니온이란 타입 간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자를 달아주어 포함 관계를 제거하는 것이다.
type TextError = {
  errorType: "TEXT";
  errorCode: string;
  errorMsg: string;
};
type ToastError = {
  errorType: "TOAST";
  errorCode: string;
  errorMsg: string;
  toastShowDuration: number; // 토스트를 띄워줄 시간
};
type AlertError = {
  errorType: "ALERT";
  errorCode: string;
  errorMsg: string;
  onConfirm: () => void; // 얼럿 창의 확인 버튼을 누른 뒤 액션
};
  • 에러 객체에 대한 타입을 위와 같이 정의한 상태에서 errorArr을 새로 정의해보자. 정확하지 않은 에러 객체에 대해 타입 에러가 발생하는 것을 확인할 수 있다.
type ErrorFeedbackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
  { errorType:"TEXT", errorCode:"100", errorMsg:"텍스트 에러" },
  {
  errorType: "TEXT",
  errorCode: "200",
  errorMsg: "토스트 에러",
  toastShowDuration: 3000,
  },
  {
  errorType: "ALERT",
  errorCode: "300",
  errorMsg: "얼럿 에러",
  OnConfirm: () => {},
  },
  {
  errorType: "TEXT",
  errorCode: "999",
  errorMsg: "잘못된 에러",
  toastShowDuration: 3000,
  OnConfirm: () => {},
  },
  // ...생략

4.3.3. 식별할 수 있는 유니온의 판별자 선정

  • 식별할 수 있는 유니온을 사용할 때 주의할 점이 있다. 식별할 수 있는 유니온의 판별자는 유닛 타입으로 선언되어야 정상적으로 동작한다.
  • null, undefined, 리터널 타입을 비롯해 true, 1 등 정확한 값을 나타내는 타입이 유닛타입이다.

4.4. Exhaustiveness Checking으로 정확한 타입 분기 유지하기

  • Exhaustiveness Checking은 모든 케이스에 대해 분기처리하는 것이다.

4.4.1. 상품권

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 {
    exhaustiveCheck(productPrice);
    return "배민상품권";
  }
};
 
const exhaustiveCheck = (param: never) => {
  throw new Error("type error!");
};
  • 위 코드에서 productPrice가 "5000"일 때의 분기처리가 주석 처리된 것으로 보일 것이다. 이렇게 모든 케이스에 대해 분기 처리를 해주지 않는 것을 Exhaustiveness Checking이라고 한다.
  • exhaustiveCheck 함수는 매개변수를 never 타입으로 선언하고 있다. 즉, 매개변수로 어떤 값도 받을 수 없으며 만일 값이 들어온다면 에러를 내뱉는다. 이 함수를 타입 처리 조건문의 마지막 else문에 사용하면 앞의 조건문에서 모든 타입에 대한 분기 처리를 강제할 수 있다.
  • 이렇게 Exhaustiveness Checking을 활용하면 예상치 못한 런타임 에러를 방지하거나 요구사항이 변경되었을 때 생길 수 있는 위험성을 줄일 수 있다.