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 패턴 활용해보기