Part4_Extends,Narrowing
장경은
책 읽기

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

4.1 타입 확장하기

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

타입 확장의 장점

  • 타입 확장의 가장 큰 장점은 코드 중복을 줄일 수 있다는 것이다.
interface BaseMenuItem {
    itemName: string | null;
    itemImageUrl: string | null;
    itemDiscountAmount: number;
    stock: number | null;
}
 
inteface 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;
  • 이렇게 사용하면 기존 메뉴 요소에 대한 요구사항이 변경되어도 BaseMenuItem만 수정하면 해결할 수 있다.

유니온 타입

  • 유니온 타입은 2개 이상의 타입을 조합하여 사용하는 방법이다.
  • 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.
interface CookingStep {
    orderId: string;
    price: number;
}
 
interface DeliveryStep {
    orderId: string;
    time: number;
    distance: string;
}
 
function getDeliveryDistance(step: CookingStep | DeliveryStep) {
    return step.distance;
    //   Error: Property distance does not exist on type CookingStep | DeliveryStep
    //   Error: Property distance does not exist on type CookingStep
}

교차 타입

  • 기존 타입을 합쳐 필요한 모든 기능을 가진 하나의 타입을 만드는 것이다.
interface CookingStep {
    orderId: string;
    time: number;
    price: number;
}
 
interface DeliveryStep {
    orderId: string;
    time: number;
    distance: string;
}
 
type BaedalProgress = CookingStep & DeliveryStep;
  • 이렇게 하면 두 타입을 합쳐 모든 속성을 가진 단일 타입이 된다.
  • 교차 타입을 사용할 때 타입이 서로 호환되지 않는 경우도 있다.
type IdType = string | number;
type Numeric = number | boolean;
type Universal = IdType & Numeric;
  • 이렇게 지정하면 Universal은 두 타입을 모두 만족하는 number가 된다.

extends와 교차 타입

  • 유니온 타입(|)과 교차 타입(&)을 사용한 새로운 타입은 오직 type 키워드로만 선언할 수 있다.
  • interface를 사용할 경우 extends 키워드를 사용해서 교차 타입을 작성할 수도 있다.
    • 주의할 점은 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
}
  • 위처럼 extends를 사용할 때 이미 상속받은 부분에 대해 새로 선언하면 에러가 발생한다.
  • 그러나 type의 &를 사용하면 에러가 발생하지 않고 해당 부분을 never로 인식한다.

4.2 타입 좁히기 - 타입가드

  • 타입 좁히기는 변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정을 말한다.

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

  • 분기 처리는 조건문과 타입 가드를 활용하여 변수나 표현식의 타입 범위를 좁혀 다양한 상황에 따라 다른 동작을 수행하는 것을 말한다.
  • 타입 가드는 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능을 말한다.
  • 타입 가드는 크게 자바스크립트 연산자를 이용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있다.
    • 자바스크립트 연산자를 활용한 타입 가드는 typeof, instanceof, in과 같은 연산자를 사용해서 제어문으로 특정 타입 값을 가질 수밖에 없는 상황을 유도하여 자연스럽게 타입을 좁히는 방식이다.
    • 사용자 정의 타입 가드는 사용자가 직접 어떤 타입으로 값을 좁힐지를 직접 지정하는 방식이다.

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

  • 자바스크립트의 동작 방식으로 인해 null과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기에는 한계가 있다.
    • 따라서 typeof 연산자는 주로 원시 타입을 좁히는 용도로만 사용할 것을 권장한다.
const replaceHyphen: (date: string | Date) => string | Date = (date) => {
    if (typeof date === string) {
      // 이 분기에서는 date의 타입이 string으로 추론된다
      return date.replace(/-/g, /);
    }
 
    return date;
};
 

인스턴스화된 객체 타입을 판별할 때: instanceof 연산자 활용하기

  • typeof 연산자를 주로 원시 타입을 판별하는 데 사용한다면, instanceof 연산자는 인스턴스화된 객체 타입을 판별하는 타입 가드로 사용할 수 있다.
  • A instanceof B 형태로 사용하며 A에는 타입을 검사할 대상 변수, B에는 특정 객체의 생성자가 들어간다.
const onKeyDown = (event: React.KeyboardEvent) => {
    if (event.target instanceof HTMLInputElement && event.key === Enter) {
        // 이 분기에서는 event.target의 타입이 HTMLInputElement이며
        // event.key가 Enter이다
        event.target.blur();
        onCTAButtonClick(event);
    }
};

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

  • in 연산자는 객체에 속성이 있는지 확인한 다음에 true 또는 false를 반환한다.
    • in 연산자를 사용하면 속성이 있는지 없는지에 따라 객체 타입을 구분할 수 있다.
  • in 연산자는 A in B의 형태로 사용하는데 이름 그대로 A라는 속성이 B 객체에 존재하는지를 검사한다.
    • 프로토타입 체인으로 접근할 수 있는 속성이면 전부 true를 반환한다.
  • in 연산자는 B 객체 내부에 A 속성이 있는지 없는지를 검사하는 것이기 때문에 B 객체에 존재하는 A 속성에 undefined를 할당한다고 해서 false를 반환하는 것은 아니다.
    • delete 연산자를 사용하여 객체 내부에서 해당 속성을 제거해야만 false를 반환한다.
const NoticeDialog: React.FC<NoticeDialogProps> = props => {
    if (cookieKey in props) return <NoticeDialogWithCookie {...props} />;
    return <NoticeDialogBase {...props} />;
};

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

  • 타입 명제는 A is B 형식으로 작성하면 되는데 여기서 A는 매개변수 이름이고 B는 타입이다.
  • 참/거짓의 진릿값을 반환하면서 반환 타입을 타입 명제로 지정하게 되면 반환 값이 참일 때 A 매개변수의 타입을 B 타입으로 취급하게 된다.
const isDestinationCode = (x: string): x is DestinationCode => {
    return destinationCodeList.includes(x);
};
  • 이렇게 사용하면 반환 값이 boolean인 것과 차이가 있다.
  • 타입스크립트에게 반환 값에 대한 타입 정보를 알려주고 싶을 때 is를 사용할 수 있다.
  • 반환 값의 타입을 x is DestinationCode로 알려줌으로써 타입스크립트는 if문 스코프의 str 타입을 DestinationCode로 추론할 수 있게 된다.
  • 객체에 [] 문법으로 키에 접근할 경우 사용할 수 있다.

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

  • 종종 태그된 유니온 (Tagged Union) 이라고도 불린다.
  • 에러 타입이 3가지가 있고, 아래와 같이 선언되어 있다고 가정하자.
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: '999',
        errorMessage: '에러',
        toastShowDuration: 3000,
        onConfirm: () => {}
    }
];
  • 위와 같은 상황에서 에러를 발생시키기 위해 식별할 수 있는 유니온을 사용할 수 있다.
  • 식별할 수 있는 유니온이란 타입 간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자(discriminant)를 달아주어 포함 관계를 제거하는 것이다.
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: ALERT,
      errorCode: 310,
      errorMessage: 얼럿 에러,
      toastShowDuration: 5000,
      // Object literal may only specify known properties,
      // and toastShowDuration does not exist in type AlertError
    },
];
 
  • 여기서는 errorType을 추가하여 식별할 수 있는 유니온의 판별자로 사용했다.
  • 식별할 수 있는 유니온의 판별자는 유닛 타입unit type으로 선언되어야 정상적으로 동작한다.
    • 유닛 타입은 다른 타입으로 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입을 말한다.
    • null, undefined, 리터럴 타입을 비롯해 true, 1 등 정확한 값을 나타내는 타입이 유닛 타입에 해당한다.
    • 반면에 다양한 타입을 할당할 수 있는 void, string, number와 같은 타입은 유닛 타입으로 적용되지 않는다.

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

  • Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말하며 타입 좁히기에 사용되는 패러다임 중 하나다.
type ProductPrice = 10000 | 20000 | 5000;
 
const exhaustiveCheck = (param: never) => {
  throw new Error(type error!);
};
 
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 assign able to parameter of type never
      return '배민상품권';
    }
};
 
  • exhaustiveCheck라는 함수가 보일 것이다. 이 함수는 매개변수를 never 타입으로 선언하고 있다.
  • 즉, 매개변수로 그 어떤 값도 받을 수 없으며 만일 값이 들어온다면 에러를 내뱉는다.
  • 이 함수를 타입 처리 조건문의 마지막 else문에 사용하면 앞의 조건문에서 모든 타입에 대한 분기 처리를 강제할 수 있다.