Part4_Extends,Narrowing
이효석
책 읽기

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

4.1 타입 확장하기

  • 기존 타입을 이용해서 새로운 타입을 정의하는 것
  • extends, 교차 타입, 유니온 타입등을 사용하여 타입을 확장

4.1.1 타입 확장의 장점

  • 중복되는 타입 선언을 확장으로 처리하여 코드 중복 방지
  • 확장을 활용하면 요구사항에 따른 타입을 손쉽게 만들어 사용이 가능
// interface 사용
interface BaseMenuItem {
  itemName: string | null;
  itemImgUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
}
 
// 확장을 사용하여 중복 코드 방지
interface BaseCartItem extends BaseMenuItem {
  quantity: number;
}
 
// type 을 사용한 예시
type BaseMenuItem = {
  itemName: string | null;
  itemImgUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
};
 
type BaseCartItem = {
  quantity: number;
} & BaseMenuItem;
 
const cart: BaseCartItem = {
  itemName: 'pizza',
  itemImgUrl: null,
  itemDiscountAmount: 10,
  stock: null,
  quantity: 1,
};

4.1.2 유니온 타입

  • 여러 타입의 합집합을 의미하는 타입
  • 유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 가지고 있는 속성에만 접근이 가능하므로 주의 필요

** [p.123] 교집합 아닌가요? ** [p.123] 속성의 집합이 아니라 값의 집합이라고 생각해야 유니온 타입이 합집합이라는 개념을 이해할 수 있다? -> ???? 어떤 값이 들어올지는 런타입에 결정이 되므로 접근이 가능한 속성은 안전성을 위해 교집합의 개념으로 접근이 가능하고, 실제 들어오는 값은 합집합의 개념이다 라고 하면 될거를....

interface CookingStep {
  orderId: string;
  price: number;
}
 
interface DeliveryStep {
  orderId: string;
  time: number;
  distance: string;
}
 
function getDeliveryDistance(step: CookingStep | DeliveryStep) {
  return step.distance; // ERR, 공통 속성이 아니므로 에러 발생
}

4.1.3 교차 타입

  • 이게 찐 합집합의 개념, & 연산자를 사용하며 모든 속성이 합집합의 개념으로 들어온다
다시 말하지만 타입스크립트의 타입을 속성의 집합이 아니라 값의 집합으로 이해해야 한다.
BaedalProgress 교차 타입은 CookingStep이 가진 속성(orderId, time, price)과 DeliveryStep
이 가진 속성(orderId, time, distance)을 모두 만족(교집합)하는 값의 타입(집합)이라고 해석할 수있다.

** [p.124] 설명이 이게 맞나여? 이걸 이렇게 설명하면 누가 알아 듣나.... 아니다... 내가 바보인가?

interface CookingStep {
  orderId: string;
  price: number;
}
 
interface DeliveryStep {
  orderId: string;
  time: number;
  distance: string;
}
 
type BedalProgress = CookingStep & DeliveryStep;
 
function logBedalInfo(progress: BedalProgress) {
  console.log(`주문 금액 : ${progress.price}`);
  console.log(`배달 거리 : ${progress.distance}`);
}
  • 교차 타입 사용시 타입이 서로 호환되지 않는 경우, 두 타입을 모두 만족하는 경우에만 유지가되므로 아래와 같은 상황이 나온다
type IdType = string | number;
type Numeric = number | boolean;
 
type Universal = IdType & Numeric; // number type 으로 지정 된다
 
const universalVal: Universal = 1;

4.1.4 extends 와 교차 타입

  • extends 키워드를 사용하면 교차 타입 작성이 가능하다
  • 단, extends 키워드를 사용한 타입은 실제 교차 타입과 100% 상응하지 않는다
  • 단, 유니온 타입과 교차 타입을 사용한 새로운 타입은 오직 type 키워드로만 선언이 가능하므로 type 을 사용해야 한다
interface DeliveryTip {
  tip: number;
}
 
// ERR, number 에 string 을 할당하려고 하므로 에러 발생
interface Filter extends DeliveryTip {
  tip: string;
}
 
// type 사용하면 교차 타입으로 선언되어 tip 은 never 가 된다
type DeliveryTipType = {
  tip: number;
};
 
type FilterType = DeliveryTip & {
  tip: string;
};

** [p.127] type 으로 사용할 경우 runtime 에 해당 타입이 할당되면 바로 ERR 가 발생할 텐데, 해당 접근법은 올바른 접근일까요?

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

  • 배민 메뉴에서 특정 경우 gif 파일과 text 가 필요한 경우 타입을 확장하는 케이스

  • 확장을 사용하지 않고, 기존 타입에 속성을 추가하는 경우

interface Menu {
  name: string;
  image: string;
  gif?: string;
  text?: string;
}
 
const specialMenuList: Menu[] = [
  { name: '찜', image: '찜.jpg', gif: '찜.gif' },
  { name: '찌개', image: '찌개.jpg', gif: '찌개.gif' },
];
 
// 매개변수로 받는 배열의 요소에 text 가 있을지 없을지를 모르고 해당 값을 처리하여 원하는 결과를 얻을 수 없다
// 해당 속성에 문자열이 빈 문자열이 아니면 배열로 리턴하는 함수이지만, 실제로는 속성 값 자체가 존재하지 않아 undefined 와 비교하여
// 실제로 원했던 결과와는 전혀 다른 결과가 발생
const getTextFromMenu = (menuList: Menu[]) => {
  return menuList.filter((menu) => menu.text != '');
};
 
console.log(getTextFromMenu(specialMenuList));
  • 타입을 확장하여 사용하는 방식, 해당 방법을 사용하면 배열의 요소의 타입을 강제할 수 있으므로 좀 더 안정적으로 사용이 가능하다
// 2. 타입 확장 활용
interface Menu {
  name: string;
  image: string;
}
 
interface SpecialMenu extends Menu {
  gif: string;
}
 
interface PackageMenu extends Menu {
  text: string;
}
 
const specialMenuList2: SpecialMenu[] = [
  { name: '찜', image: '찜.jpg', gif: '찜.gif' },
  { name: '찌개', image: '찌개.jpg', gif: '찌개.gif' },
];
 
const getGifUrlFromSpecialMenuList = (specialMenuList: SpecialMenu[]) => {
  return specialMenuList.filter((specialMenu) => specialMenu.gif != '');
};
 
console.log(getGifUrlFromSpecialMenuList(specialMenuList2));

4.2 타입 좁히기 - 타입 가드

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

  • 타입 가드는 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능
  • 주로 typeof, instanceof, in 과 같은 연산자를 사용하여 처리

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

  • typeof 연산자를 활용하여 검사할 수 있는 타입 목록
  • string / number / boolean / undefined / object / function / bigint / symbol
// 함수 타입을 먼저 지정하고, 변수에 타입을 할당한 뒤 함수 구현
const replaceHyphen: (date: string | Date) => string | Date = (date) => {
  if (typeof date === 'string') {
    return date.replace(/-/g, '/');
  }
 
  return date;
};
 
// 변수에 함수를 직접 할당, 위와 달리 TS 가 함수를 보고 함수의 타입을 추론
const replaceHyphen2 = (date: string | Date): string | Date => {
  if (typeof date === 'string') {
    return date.replace(/-/g, '/');
  }
 
  return date;
};

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

  • 인스턴스화 된 객체 타입을 판별하는 타입 가드가 필요할 때에는 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;
}

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

  • A in B 로 사용, A 속성이 B 객체에 존재하는지 여부를 체크
interface BasicNoticeDialogProps {
  noticeTitle: string;
  noticeBody: string;
}
 
interface NoticeDialogWithCookieProps extends BasicNoticeDialogProps {
  cookieKey: string;
  noForADay?: boolean;
  neverAgain?: boolean;
}
 
export type NoticeDialogProps =
  | BasicNoticeDialogProps
  | NoticeDialogWithCookieProps;
 
// 받아온 매개변수 객체에 cookieKey 속성이 존재하는지를 확인 후, 각기 다른 컴포넌트를 반환하는 훅
const NoticeDialog: React.FC<NoticeDialogProps> = (props) => {
  if ('cookieKey' in props) return <NoticeDialogWithCookie {...props} />;
  return <NoticeDialogProps {...props} />;
};

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

  • A is B 형태로 사용, 함수의 리턴 값이 true 일 경우 A 매개 변수 타입을 B 타입으로 취급
export type EntityType = IDocument | IFolder;
 
export const documentGuard = (item: EntityType): item is IDocument => {
  // 해당 조건이 참이면 item 은 IDocument 타입
  return item.itemType === 'document'; // 명제를 만족할 조건(boolean 값을 반환해야 함)
};
 
export const folderGuard = (item: EntityType): item is IFolder => {
  // 해당 조건이 참이면 item 은 IFolder 타입
  return item.itemType === 'folder'; // 명제를 만족할 조건(boolean 값을 반환해야 함)
};
  • 배민에서 사용하는 코드 예시
// x 매개변수가 destinationCodeList 배열에 포함되어 있으면 x 는 DestinationCode 이므로 x is DestinationCode 에 의해 DestinationCode 타입으로 처리
const isDestinationCode = (x: string): x is DestinationCode =>
  destinationCodeList.includes(x);
 
const getAvailableDestinationNameList = async (): Promise<
  DestinationName[]
> => {
  const data = await AxiosRequest<string[]>('get', '.../destinations');
  const destinationNames: DestinationName[] = [];
  data?.forEach((str) => {
    // 타입 명제를 사용하는 isDestinationCode 를 사용해야만 TS 가 str 매개변수를 DestinationCode 타입으로 좁힐 수 있고 그렇지 않을경우 string 으로 추론하여 아래의 에러가 발생한다
    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;
};

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

4.3.1 에러 정의하기

  • JS 는 덕타이핑 언어이므로 아래와 같이 유니온 타입으로 타입 에러가 발생하지 않는 문제가 발생한다
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: () => {} },
];
 
// 아래의 요소는 타입에 어긋나지만 JS 는 덕타이핑 언어이므로 별도의 타입 에러가 발생하지 않는 문제 발생
const errArr: ErrorFeedbackType[] = [
  {
    errorCode: '999',
    errorMessage: '잘못된에러',
    toastShowDuration: 3000,
    onConfirm: () => {},
  },
];

4.3.2 식별할 수 있는 유니온

  • 위의 문제를 식별할 수 있는 유니온을 활용하면 해결이 가능하다
  • 타입간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자를 달아 포함 관계를 정의하는 방법이다
  • 타입에 특정 갑을 고정으로 가지는 속성을 선언하여 덕타이핑으로 발생하는 문제를 피하는 방법이다
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 errArr: ErrorFeedbackType[] = [
  {
    errorType: 'TEXT',
    errorCode: '999',
    errorMessage: '잘못된에러',
    toastShowDuration: 3000, // errorType: 'TEXT' 로 인하여 ERR 발생
    onConfirm: () => {}, // errorType: 'TEXT' 로 인하여 ERR 발생
  },
];

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

  • 식별할 수 있는 유니온의 판별자는 쪼개질 수 없는 유닛 타입(null, undefined, true, 1. 리터럴)으로 선언되어야 정상적으로 동작한다
  • 유닛 타입이 아니거나 할당이 가능한 타입(string, number, void)은 타입 좁히기의 기능 자체가 동작을 하지 않게 된다

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

  • Exhaustiveness Checking 으로 타입 검사를 강제하여 안전하게 처리 가능

4.4.1 상품권

  • Exhaustiveness Checking 하지 않을 경우 아래의 코드는 어느 한쪽에서 처리를 깜박할 경우 버그 발생
type ProductPrice = '10000' | '20000' | '5000';
 
// type 의 값이 추가 될때 마다 함수의 조건도 추가되어야 하는 구조 -> 어느 한쪽이 잘못되면 예상치 못한 버그 발생 가능
const getProductName = (productPrice: ProductPrice): string => {
  if (productPrice === '10000') return '배민상품권 1만원';
  if (productPrice === '20000') return '배민상품권 2만 원';
  if (productPrice === '5000') return '배민상품권 5천 원'; // 조건 추가 필요
  else {
    return '배민상품권';
  }
};
  • 매개변수를 never 로 받는 Exhaustiveness Checking 을 추가하여 Early 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 {
    exhaustiveCheck(productPrice); // Error: Argument of type ‘string’ is not assign able to parameter of type ‘never’
    return '배민상품권';
  }
};
 
// 매개변수를 never 로 처리하여, getProductName 에서 early return 으로 처리되지 않아 exhaustiveCheck 가 실행이 되면 Type 에러
const exhaustiveCheck = (param: never) => {
  throw new Error('type error!');
};

** [p.147] 해당 패턴은 매우 좋네요! ** [p.148] 프로덕트 코드에 삽인하는 어설션과 테스트 코드에 대한 시각도 재미있네요. 다들 어찌 생각하시나요?