Pandaman Blog

[Typescript] Type Guard 본문

Front end/Typescript

[Typescript] Type Guard

oyg0420 2021. 12. 18. 17:46

1. Type Guard (타입 가드)

목표

타입 가드에 대해서 학습을 통해서 타입의 안전성을 높일 수 있는 방법들에 대해 알아보자.

Type Guard 란?

Type guard는 TypeScript만의 독특한 기능으로, 분기한 블록 안에서 해당 변수의 타입을 한정시켜주는 기능을 말한다.

다음은 타입 가드의 방법이다.

1) typeof

타입 스크립트는 typeof 연산자를 이해할 수 있다. 즉 조건문에 typeof를 사용하면 해당 조건문 블록 내에서 해당 변수의 타입이 다르다는 것을 이해한다. 타입 스크립트 컴파일러 과정에서 typeof를 타입 가드로 이해한다.

예를 들면 아래와 같다.
링크

const trimData = (data: string | number) => {
    if (typeof data === 'string') {
        return data.trim();
    }

  return data.toString().trim();
};

trimData 함수는 data의 앞뒤 공백을 잘라낸 값을 반환한다. data 인자는 string 또는 number 타입의 인자이므로 우리는 해당 타입에 맞는 메서드를 사용하여 기대하는 값은 반환해야한다. 여기서 우리는 typeof를 통해서 타입 가드를 할 수 있다. string 인 경우 trim 메서드를 바로 사용하고 string 타입이 아니라면(number 타입이라면) string 타입으로 변환 후 trim메서드를 사용하여 의도한 값을 에러 없이 반환할 수 있다.
typeof 타입 가드는 가드 하기 위한 값이 기본 타입 경우 사용하면 좋다. Array, 객체 등을 typeof로 모두 object 타입이라.. 구분이 되지 않는다.

Equality narrowing

타입을 좁히기 위한 동등 연산사를 사용하여 타입을 좁힐 수 있다. 프로젝트 내부에서 이런 경우를 찾기가 어려웠다. 그래서 아~ 이런 게 있구나 하고 넘어가도 될 것 같다.

링크

const testFunction = (x: string | boolean, y: string | number) => {
  if (x === y) {
    x.toUpperCase();
    y.toUpperCase();
  } else {
    console.log(x);
    console.log(y);
  }
}

동일한 x과 y가 동일한 경우는 string인 경우이다. === 블록 내부의 x, y의 타입은 string인 것을 확인할 수 있다.

The in operator narrowing

object의 프로퍼티 유무로 처리하는 경우로 여러 객체의 타입의 인자를 구분할 때 사용하면 편하다.
예를 들어 리스트에 타입이 다른 컴포넌트를 열거하는 경우가 꽤 발생한다. 어떻게 타입을 체크하고 타입에 대한 확신을 가질 수 있을까?

아래 예제를 한번 살펴보자. CurationCard.tsx

이전

export interface SellerSummary {
  PID: number;
  USERID: number;
  username: string;
  image: string;
  av_time_from: number;
  av_time_to: number;
}

export interface CurationGig {
  PID: number;
  title: string;
  thumbnail: string;
  categories: CategoriesInfo;
  seller: {
    USERID: number;
  }
}

export interface CurationData {
  who : 'gigs' | 'sellers';
  list: SellerSummary[] | CurationGig[]
}

//  list: SellerSummary[] | CurationGig[]

{
  curationData.who === 'sellers'
    && curationData.list?.map((item) => (
      <SellerCardWrapper key={item.PID}>
        <SellerProfileCard
          sellerProfile={item} // 1) 에러발생: item은 SellerSummary 타입이 아니다.
          PID={PID}
          isContactable
        />
      </SellerCardWrapper>
    ));
}
{
  curationData.who === 'gigs'
    && curationData.list?.map((item) => (
      <ServiceCardWrapper key={item.PID}>
        <SellerServiceCard
          gig={item} // 2) 에러발생: item은 CurationGig 타입이 아니다.
          onClick={() => {
            handleServiceCardClick(item);
          }}
        />
      </ServiceCardWrapper>
    ));
}

이경우 에러가 발생했다. 그 이유를 살펴보니 CurationData에서 who, list 유니온 타입으로 되어 있으나, what: 'sellers' 일 때, list: SellerSummary 다.라는 타입 구조가 아니다. 그냥 어떤 who가 어떤 타입이든 list 타입은 SellerSummary[] 또는 CurationGig[] 이다.

그럼 어떻게 할 수 있을까? 두 가지 방법이 떠오른다. 일단 이곳에서는 in 연산자를 사용해서 해결해보자.

SellerSummary과 CurationGig 타입을 보니 확실이 구분할 수 있는 속성이 보인다. "username", "categories"이다.
이제 who는 필요 없다. list.map 블록에서 in 연산자를 사용해 타입 가드를 할 수 있다.

이후

//  list: SellerSummary[] | CurationGig[]
       {
            list.map((item) => {
              if ('username' in item) {
                return (
                  <SellerCardWrapper key={item.PID}>
                    <SellerProfileCard
                      sellerProfile={item}
                      PID={PID}
                      isContactable
                    />
                  </SellerCardWrapper>
                );
              }
              if ('categories' in item) {
                return (
                  <ServiceCardWrapper key={item.PID}>
                    <SellerServiceCard
                      gig={item}
                      onClick={() => {
                        handleServiceCardClick(item);
                      }}
                    />
                  </ServiceCardWrapper>
                );
              }
            })
          }

item이 SellerSummary 경우를 생각해보면 SellerSummary에는 'username' 속성이 존재하고 CurationGig에는 존재하지 않는다. if ('username' in item) {으로 타입 가드를 하면 블록 내부의 item의 타입은 SellerSummary가 되는 것을 확인할 수 있다.
이처럼 속성 유무를 이용해 타입 가드를 해줄 수 있는 좋은 예이다.

literal type Guard

literal type 가드도 마찬가지로 사용하는 경우가 굉장히 많다. 어떤 경우 사용하냐면 객체 중 어떠한 한 속성이 같고, 타입이 다른 경우에 사용할 수 있다. 말로 설명하니 사실 잘 이해가 안 될 수 있기 때문에 코드로 살펴보자.

링크

interface Dog {
  species: 'dog';
  size: number;
  color: string;
}

interface Man {
  species: 'human';
  old: number;
  address: string;
}

function introduce(thing: Dog | Man) {
  if (thing.species === 'dog') {
    return `Hi. I'm dog. my color is ${thing.color}`;
  }

  return `Hi. I'm human. my address is ${thing.address}`;
}

Dog, Man 타입이 존재하고 species라는 속성이 모두 존재한다. if (thing.species === 'dog') { 블록 내부에서는 thing를 Dog타입으로 인식하여 Dog타입의 속성에 접근할 수 있게 된다. 너무 좋지 않은가.

위의 in 연산자에서 타입 가드로 이용했던 CurationCard.tsx를 다시 살펴보자.

export interface SellerSummary {
  PID: number;
  USERID: number;
  username: string;
  image: string;
  av_time_from: number;
  av_time_to: number;
}

export interface CurationGig {
  PID: number;
  title: string;
  thumbnail: string;
  categories: CategoriesInfo;
  seller: {
    USERID: number;
  }
}

export interface CurationData {
  who : 'gigs' | 'sellers';
  list: SellerSummary[] | CurationGig[]
}

CurationData 타입을 who가 어떤 타입일 때 list는 어떤 타입일지를 타입 스크립트에게 알려주기 위해 타입을 분리시켜도 될 것 같다는 생각이 든다.
그리고 분리된 타입을 유니온 타입으로 변경한다.

interface CurationGigs {
 who: 'gigs';
 list: CurationGig[];
}

interface CurationSeller {
 who: 'sellers';
 list: SellerSummary[];
}

export type CurationData = CurationGigs | CurationSeller

...
{
  curationData.who === 'sellers'
    && curationData.list?.map((item) => (
      <SellerCardWrapper key={item.PID}>
        <SellerProfileCard
          sellerProfile={item}
          PID={PID}
          isContactable
        />
      </SellerCardWrapper>
    ));
}
{
  curationData.who === 'gigs'
    && curationData.list?.map((item) => (
      <ServiceCardWrapper key={item.PID}>
        <SellerServiceCard
          gig={item}
          onClick={() => {
            handleServiceCardClick(item);
          }}
        />
      </ServiceCardWrapper>
    ));
}

이제는 타입 스크립트는 명확하게 who가 'sellers'일 때 list가 SellerSummary 타입인 것을 추론할 수 있게 되었다.

또 다른 예제를 살펴보자. DynamicControl.tsx

const ComponentByInputType: {
  [key in InquiryOptionType]: (props: {
    option: OptionTypes;
    errorMessage: ReactNode;
  }) => JSX.Element;
} = {
  TEXT: Text,
  EMAIL: Email,
  PRICE: Price,
  TEXTAREA: Textarea,
  DROPDOWN: Dropdown,
  DATE: Date,
  RADIO: Radio,
  SWITCH: Switch,
  FILE: File,
};

ComponentByInputType는 인덱스를 사용하여 원하는 컴포넌트를 반환하는 객체이다. 여기서 주목할 타입은 OptionTypes인데, OptionTypes은 아래와 같이 이루어져 있다.

export type OptionTypes =
  | TextOption
  | EmailOption
  | PriceOption
  | DateOption
  | DropdownOption
  | TextareaOption
  | RadioOption
  | SwitchOption
  | FileOption

각각의 옵션 타입에는 공통적인 속성이 존재하니, 바로 input_type이다. input_type 은 text, email 등 리터럴로 타입이 정의되어 있다.

다시 위의 ComponentByInputType로 돌아가면, 잘못된 점을 찾을 수 있다. 해당 인덱스에 따라 props로 option을 할당받는데 option의 타입인 OptionTypes가 타입 가드가 되지 않아 각각의 객체의 컴포넌트 TEXT: Text, EMAIL: Email, 의 option은 정확한 타입이 정해지지 않은 체 할당되는 상황이 발생된다. 결국 tsc 옵션을 strict: true로 변경해보면 에러가 난다.

결국 이 상황을 해결하기 위한 방법은 아래와 같이 리터럴 타입 가드를 하는 것이다.
if (option.input_type === InquiryOptionType.Text) {

아래는 전체 코드이다.

 if (option.input_type === InquiryOptionType.Text) {
    return (
      <Text
        option={option} // TextOption
        errorMessage={errorMessage}
      />
    );
  }
  if (option.input_type === InquiryOptionType.Email) {
    return (
      <Email
        option={option} // EmailOption
        errorMessage={errorMessage}
      />
    );
  }

블록 내부의 option은 우리가 의도한 option 들로 결정되어 있다. 이렇게 타입 가드를 하니 더욱더 타입에 대한 안전성이 높아진 기분이 든다.

instanceof narrowing

instanceof 연산자는 생성자의 prototype 속성이 객체의 프로토타입 체인 어딘가 존재하는지 판별합니다.
즉, 생성자 함수나 클래스를 new를 통해 생성된 인스턴스를 instanceof 연산자를 통해 타입 가드 하는 방식이다.

아래 예제를 한번 살펴보자.

class NegativeNumberError extends Error {};

function getNumber(value: number) {
  if (value < 0) {
    return new NegativeNumberError();
  }

  return value;
}

function main() {
  const num = getNumber(-10);

  if (num instanceof NumberError) {
     console.log(num);
    return;
  }

  console.log(num);
}

getNumber 함수는 value가 0 < 0라면 NegativeNumberError를 생성하여 인스턴스를 반환하고 아니라면 value를 반환한다. 자 이제 main 함수의 if (num instanceof NumberError) { 블록 내부의 num의 타입을 보면 NumberError 타입인 것을 마우스를 올려보면 확인할 수 있다.

Using type predicates

사용자 정의 타입 가드를 정의하려면 반환 타입을 작성하는 자리에 타입 서술어를 작성한다.
링크

 

예를 들어보자.

type Fish = { swim: () => void };
type Bird = { fly: () => void };

declare function getSmallPet(): Fish | Bird;

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

let pet = getSmallPet();

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

여기서 isFish 함수의 리턴 타입의 정의하는 부분을 주목해보자. pet is Fish는 타입 서술어이다. 매개변수인 pet의 타입은 Fish라는 의미로 작성되었다. 서술어의 포맷은 파라미터이름 is Type이다. 무조건 파라미터 이름과 동일해야 한다. Fish 타입이 아니라면 false 가 될 것이고, 맞으면 true가 되어 조건문에서 사용될 경우 타입 가드 역할을 해준다.

약간 억지일 수 있겠지만, 타입 서술어를 통해 타입 가드를 하는 것, typeof로 타입 가드 하는 것을 아래와 같이 만들어 볼 수 있다.

typeof value === 'number'

function isNumber(value: string | number): value is number {
  return typeof value === 'number';
}

'Front end > Typescript' 카테고리의 다른 글

[Typescript] Utility Types - 2  (0) 2022.03.27
[Typescript] Conditional Types  (0) 2022.03.13
[Typescript] Mapped Types  (0) 2022.02.24
[Typescript] Utility Types - 1  (0) 2022.02.21
[Typescript] 함수  (0) 2021.12.19
Comments