[우아한 타입스크립트(with 리액트)] 4장. 타입 확장하기, 좁히기

Rachel·2024년 4월 12일
0

우아한형제들 웹프론트엔드개발그룹, 『우아한 타입스크립트 with 리액트』, 한빛미디어(2023) p.80 ~ 118

4.1 타입 확장하기

1. 타입 확장의 장점

  1. 중복 제거
  2. 명시적인 코드 작성
  3. 확장성
    (요구사항 변경시 공통 기본 요소 한 번에 수정 가능)

interface 확장

interface BaseMenuItem {
  itemName: string | null;
  itemImageUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
}

interface BaseCartItem extends BaseMenuItem {
  quantity: number;
}

type 확장

type BaseMenuItem = {
  itemName: string | null;
  itemImageUrl: string | null;
  itemDiscountAmount: number;
  stock: number | null;
};

type BaseCartItem = {
  quantity: number;
} & BaseMenuItem;

✍️ interface는 선언적 확장이 가능하다 -> 같은 이름의 interface를 선언하면, 자동으로 확장.

2. 유니온 타입 => 합집합(값의 집합)

2개 이상의 타입을 조합해서 사용하는 방법.

  • 유니온 타입으로 선언된 타입에 포함된 모든 타입이 공통으로 갖고 있는 속성에만 접근할 수 있다.
type MyUnion = A | B;

3. 교차 타입 => 교집합

type MyIntersection = A & B;
interface CookingStep {
  orderId: string;
  time: number;
  price: number;
}

interface DeliveryStep {
  orderId: string;
  time: number;
  distance: string;
}

type BaedalProgress = CookingStep & DeliveryStep;

유니온 타입과 다르게 BaedalProgress는 CookingStep이 가진 속성과 DeliveryStep이 가진 속성을 모두 만족(교집합)하는 값의 타입(집합)이라고 해석할 수 있다.

  • 타입스크립트의 타입을 속성의 집합이 아니라 값의 집합으로 이해해야 한다.
interface DeliveryStep {
    tip: string;
}

interface StarRating {
    rate: number;
}


type filter: Filter = {
    tip: '1000원 이하',
    rate: 4,
};

-> 두 타입간 서로 교집합이 없어도 타입은 값의 집합이므로 속성을 모두 포함한 타입이 된다.

교차타입이지만 타입이 서로 호환되지 않는 경우

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인 경우

두 타입을 모두 만족하는 경우에만 유지되므로 Universal의 타입은 number

4. extends와 교차 타입

  • 주어진 타입에 무분별하게 속성을 추가하여 사용하는 것보다 타입을 확장해서 적절한 네이밍을 사용하는 것이 타입의 의도를 명확히 표현할 수 있고 코드 작성 단계에서 예기지 못한 버그도 예방할 수 있다.

4.2 타입 좁히기 - 타입 가드

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

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

분기 처리: 조건문과 타입 가드를 활용하여 변수나 표현식의 타입 범위를 좁혀 상황에 따라 다른 동작을 수행하는 것을 말한다.

타입 가드: 런타임에 조건문을 사용하여 타입을 검사하고 타입 범위를 좁혀주는 기능을 말한다.

✏️ 스코프(scope)
타입스크립트에서 스코프는 변수와 함수 등의 식별자가 유효한 범위를 나타낸다. 즉 변수와 함수를 선언하거나 사용할 수 있는 영역을 말한다.

👉 if문을 사용하면 될 것 같지만 컴파일시 타입 정보는 모두 제거!되어 런타임에 존재하지 않기 때문에 타입을 사용해서 조건을 만들 수 없다.

컴파일해도 타입 정보가 사라지지 않는 방법

  1. 자바스크립트 연산자를 이용한 타입 가드
  2. 사용자 정의 타입 가드

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

typeof는 자바스크립트 타입 시스템만 대응 가능

  • string
  • number
  • boolean
  • undefined
  • object
  • symbol
  • function
  • bigInt

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

const onKeyDown = (event: React.KeyboardEvent) => {
  if (event.target instanceof HTMLInputElement && event.key === "Enter") {
    event.target.blur();
    onCTAButtonClick(event);
  }
};

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

in 연산자는 객체에 속성이 있는지 확인한 다음에 true 또는 false를 반환한다. in 연산자를 사용하면 속성이 있는지 없는지에 따라 객체 타입을 구분할 수 있다.

interface BasicNoticeDialogProps {
    noticeProps {
        noticeTitle: string;
        noticeBody: string;
    }
}

interface NoticeDialogWithCookieProps extends BasicNoticeDialProps {
    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 연산자는 런타임의 값만을 검사하지만 타입스크립트에서는 객체 타입에 속성이 존재하는지 검사한다.

✏️ 얼리 리턴(Early return)
특정 조건에 부합하지 않으면 바로 반환하는 것을 말한다.

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

반환 타입이 타입 명제(type predicates)인 함수를 정의해 직접 가드 함수를 만들어 사용할 수 있다.
타입 명제는 A is B 형식으로 작성하면 되는데 여기서 A는 매개변수 이름이고 B는 타입이다.
참/거짓의 진릿값을 반환하면서 반환 타입을 타입 명제로 지정하게 되면 반환 값이 참일 때 A의 매개변수의 타입을 B 타입으로 취급하게 된다.

✏️ 타입 명제(type predicates)
타입 명제는 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 특별한 형태의 함수이다.

const isDestinationCode = (x: string): x is DestinationCode =>
  destinationCodeList.includes(x);

isDestinationCode는 string 타입의 매개변수가 destinationCodeList 배열의 원소 중 하나인지를 검사하여 boolean을 반환하는 함수이다.


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

1. 에러 정의하기

유효성 에러
-> 에러 코드
-> 에러 메시지

  • 추가 필요한 정보

2. 식별할 수 있는 유니온

각 타입이 비슷한 구조를 가지지만 서로 호환되지 않도록 만들어주기 위해서는 타입들이 서로 포함 관계를 가지지 않도록 정의해야 한다 -> 식별할 수 있는 유니온을 활용하는 것

type TextError = {
  errorType: "TEXT";
  errorCode: string;
  erorrMessage: string;
};

type ToastError = {
  errorType: "TOAST";
  errorCode: string;
  errorMessage: string;
  toastShowDuration: number;
};

type AlertError = {
  errorType: "ALERT";
  errorCode: string;
  errorMessage: string;
  onConfirm: () => void;
};

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

식별할 수 있는 유니온의 판별자는 유닛 타입(unit type)으로 선언되어야 정상적으로 동작한다. 유닛 타입은 다른 타입으로 쪼개지지 않고 오직 하나의 정확한 값을 가지는 타입을 말한다(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(not unit)
  answer: 3;
}

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

Exhaustiveness = 철저함, 완전함
Exhaustiveness Checking = 모든 케이스에 대해 철저하게 타입을 검사하는 것을 의미

1. 상품권

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 "배민 상품권";
  }
};

const exhaustiveCheck = (param: never) => {
  throw new Error("type error");
};

ProductPrice가 업데이트 되었을 때 exhaustiveCheck를 해주지 않으면 getProductName 함수에서 에러를 뱉지 않아 실수할 여지가 있다. 이를 방지하기 위해 모든 케이스에 대한 타입 분기 처리를 해주지 않았을 때, 컴파일타임 에러가 발생하게 하는 것을 Exhaustiveness Checking이라고 한다.

exhaustiveCheck 함수는 매개변수로 never 타입을 선언하고 있다. 즉, 매개변수로 그 어떤 값도 받을 수 없으며 만일 값이 들어온다면 에러를 내뱉는다. 이 함수를 타입 처리 조건문의 마지막 else문에 사용하면 앞의 조건문에서 모든 타입에 대한 분기 처리를 강제할 수 있다.

👀 프로덕션에 어서션(Assertion)을 추가하는 것도 하나의 패턴
단위 테스트의 어서션 -> 특정 단위의 결과를 확인하는 느낌, 코드상의 어서션은 코드 중간중간에 무조건 특정 값을 가지는 상황을 확인하기 위한 디버깅 또는 주석 같은 느낌

『리팩터링 2판』 - '테스트 코드가 있다면 어서션이 디버깅 용도로 사용될 때의 효용은 줄어든다. 단위 테스트를 꾸준히 추가하여 사각을 좁히면 어서션보다 나을 떄가 많다. 하지만 소통 측면에서는 어서션이 여전히 매력적이다.'
단점: 프로덕션 코드에 assert 성격의 코드가 들어가면 번들 사이즈가 커진다.


🌟 내가 실제 프로젝트에서 고민한 타입스크립트

1. 타입 확장

농구 관련 프로젝트를 하면서 농구장에 대한 데이터, 타입이 많이 생성되었다. 비슷하지만 서로 다른 속성을 한두 개씩 가지고 있기도 하고 추가되기도 한다.

농구장 지도와 제보는 내가 맡고, 제보를 관리할 수 있는 관리자 페이지를 다른 팀원이 맡았는데 개발 과정에서 내가 백엔드와 소통하면서 바꾼 농구장 타입이 관리자 페이지에 반영이 안됐다. 문제 원인은 관리자 페이지를 만드는 팀원이 내가 만든 타입을 가져다가 쓰지 않고 다 새로 만든 부분이 컸다. 이를 내가 리팩토링하며 타입을 확장해 같이 수정되게 하고 중복을 줄이려고 노력했다.

🚨 리팩토링 이전 - 완전히 하나하나 새로 쓰여져서 사용되고 있는 코드

export interface CourtData {
  courtId: number; // 필수
  courtName: string; // 필수
  address: string; // 필수
  latitude: number; // 필수
  longitude: number; // 필수
  courtType: string;
  indoorOutdoor: string;
  courtSize: string;
  hoopCount: number;
  nightLighting: boolean;
  openingHours: boolean;
  fee: boolean;
  parkingAvailable: boolean;
  phoneNum: string;
  website: string;
  convenience: string[];
  additionalInfo: string;
  photoUrl: string;
  informerId: number;
}

export interface PutCourtData {
  courtType: string;
  indoorOutdoor: string;
  courtSize: string;
  hoopCount: number;
  nightLighting: boolean;
  openingHours: boolean;
  fee: boolean;
  parkingAvailable: boolean;
  phoneNum: string;
  website: string;
  convenience: string;
  additionalInfo: string;
  photoUrl: string;
  informerId: number;
  chatroomId: number;
}

export interface AdminCourtDetailsProps {
  data: {
    courtId: number;
    courtName: string;
    address: string;
    latitude: number;
    longitude: number;
    courtType: string;
    indoorOutdoor: string;
    courtSize: string;
    hoopCount: number;
    nightLighting: boolean;
    openingHours: boolean;
    fee: boolean;
    parkingAvailable: boolean;
    phoneNum: string;
    website: string;
    convenience: string[];
    additionalInfo: string;
    photoUrl: string;
    informerId: number;
  };
  onClose: () => void;
}

🧡 리팩토링 후 - 농구장 제보 관련 타입 2개로 줄임

export interface BasketballCourtReport {
  file: File | null;
  courtName: string;
  address: string;
  latitude: number;
  longitude: number;
  courtType: string | null;
  indoorOutdoor: string | null;
  courtSize: string | null;
  hoopCount: number | null;
  nightLighting: string | null; // default: 없음
  openingHours: string | null; // 제한
  fee: string | null; // 무료
  parkingAvailable: string | null; // 불가능
  phoneNum: string | null;
  website: string | null;
  convenience: string[] | null;
  additionalInfo: string | null;
}

export interface BasketballCourtReportAdmin
  extends Omit<BasketballCourtReport, "file"> {
  courtId: number;
  photoUrl: string;
  informerId: number;
}

결과적으로는 다 따로 쓸데없이 많이 생성되었던 타입들을 모두 지우고 BasketballCourtReportAdmin만 남길 수 있게 되었다. 추가할 속성은 적어주면 되고, file 속성만 다른 점이 고민이었는데 찾아보니 Omit을 활용할 수 있어 이를 적용했다. 결과적으로 코드 수가 줄었고 직관적으로 알아보기 좋으며 한 번에 업데이트 가능해졌다!

🤔 고민해볼 점 - 상속 가능한 데이터들은 모두 상속하는게 좋을까?
지금 내 생각으로는 같은 류의 데이터이고 크게 변경될 가능성(속성 제거, 추가)이 없다면 상속을 사용하는 것이 한 번에 업데이트가 용이하고 알아보기 좋을 것 같다는 생각이다. 상속 -> 상속 -> 상속 -> 상속이 아니라 기본 데이터 -> 상속, 기본 데이터 -> 상속, 기본 데이터 -> 상속 이런 느낌이랄까?


2. 이벤트 함수 타입 지정

원래 프로젝트에 Enter event를 몇군데 넣어놨는데 넣지 않은 곳에 팀원분이 넣으면 더 편리할 것 같다고 의견을 주셔서 더 추가하게 되면서 handleKeyDown 이벤트 함수로 다 분리하고, 타입을 명확히 명시해줬다.

// Input 요소
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      handleGoBack();
    }
  };
profile
기존 블로그: https://hi-rachel.tistory.com

0개의 댓글