[Typescript] 타입의 확장과 축소

mainsain·2024년 2월 19일

Typescript

목록 보기
7/8
post-thumbnail

1️⃣ ] 타입 확장하기

기존 타입을 사용해서 새로운 타입을 정의하는 것을 말한다.

[Typescript] 고급타입 한번에 보기 ← 여기에 기초적인 용어들을 정리했다.

예제 - 배달의 민족 메뉴 시스템

1인분족발, 보쌈찜, 탕, 찌개돈까스, 회, 일식피자
/**
* 메뉴에 대한 타입
* 메뉴 이름과 메뉴 이미지에 대한 정보를 담고 있다
*/
interface Menu {
  name: string;
  image: string;
}

메뉴 인터페이스를 표현했다.

function MainMenu() {
  // Menu 타입을 원소로 갖는 배열
  const menuList: Menu[] = [{name:1인분”, image:1인분.png”}, ...]
  return (
    <ul>
      {menuList.map((menu) => (
        <li>
          <img src= {menu.image} />
          <span>{menu.name}</span>
        </li>
      ))}
    </ul>
  )
}

그리고 개발자는 메뉴 인터페이스를 기반으로 사용자에게 위 화면을 보여줬다.

⚠️ 요구사항 추가
1. 특정 메뉴를 길게 누르면 gif 파일을 재생시켜 주세요
2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출시켜 주세요

/**
* 각 배열은 서버에서 받아온 응답 값이라고 가정
*/
const menuList = [
  { name: "찜", image: "찜.png" },
  { name: "찌개", image: "찌개.png" },
  { name: "회", image: "회.png" },
];

const specialMenuList = [
  { name: "돈까스", image: "돈까스.png", gif: "돈까스.gif" },
  { name: "피자", image: "피자.png", gif: "피자.gif" },
];

const packageMenuList = [
  { name: "1인분", image: "1인분.png", text: "1인 가구 맞춤형" },
  { name: "족발", image: "족발.png", text: "오늘은 족발로 결정" },
];

위처럼 3가지 종류의 메뉴 목록이 있을 때, 각 방법을 적용해보자.

두 가지 방법으로 생각해볼 수 있다.

[방법 1️⃣ ] 하나의 타입에 여러 속성을 추가

/**
* 방법1 타입 내에서 속성 추가
* 기존 Menu 인터페이스에 추가된 정보를 전부 추가
*/
interface Menu {
  name: string;
  image: string;
  gif?: string; // 요구 사항 1. 특정 메뉴를 길게 누르면 gif 파일이 재생되어야 한다
  text?: string; // 요구 사항 2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다
}
menuList: Menu[] // OK
specialMenuList: Menu[] // OK
packageMenuList: Menu[] // OK

각 메뉴 목록은 Menu[]로 표현할 수 있다.

specialMenuList.map((menu) => menu.text); 
// TypeError: Cannot read properties of undefined

근데 배열의 원소가 각 속성에 접근한다고 했을 때, 문제가 발생했다.

  • specialMenuListMenu타입의 원소를 갖기 때문에 text 속성에 접근할 수 있지만
  • 배열의 모든 원소가 text를 갖고 있는게 아니다.

[방법 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. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다
}
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’

이를 바탕으로 specialMenuList 배열의 원소 내 속성에 접근한다고 하더라도, 프로그램을 실행하지 않아도 타입이 잘못되었음을 미리 알 수 있다.

2️⃣ ] 타입 좁히기 - 타입 가드

변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정

  • 더 정확하고 명시적인 타입 추론
  • 복잡한 타입을 작은 범위로 축소 → 타입 안정성 높임

1️⃣ ] 타입 가드에 따라 분기 처리하기

조건문과 타입 가드를 활용해 타입 범위를 좁히자.

타입 가드 : 런타임에 조건문을 사용하여 타입을 검사하고, 타입 범위를 좁혀주는 기능
스코프(scope) : 변수와 함수 등의 식별자가 유효한 범위. 변수와 함수를 선언하거나 사용할 수 있는 영역을 말한다. JS 실행컨텍스트, 스코프

런타임에 유효한 타입 가드를 위해서, 자바스크립트 연산자를 사용해야 하며 typeof, instanceof, in 연산자를 활용할 것이다. 그리고 사용자 정의 타입가드로 마무리한다.

2️⃣ ] 원시 타입을 추론할 때 : typeof 연산자

typeof 연산자를 활용하면 원시 타입에 대해 추론할 수 있다.

  • typeof A === B를 조건으로 분기처리하면, 해당 분기 내에서 A의 타입이 B로 추론된다.
  • 그러나 typeof는 자바스크립트 타입 시스템만 대응할 수 있다.
  • JS 동작 방식으로 인해, null과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기엔 한계가 있다.
  • 따라서 typeof 연산자는 주로 원시 타입을 좁히는 용도로 사용할 것을 권장한다.

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;
};

3️⃣ ] 인스턴스화된 객체 타입을 판별할 때 : 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;
}

selected 매개변수가 Date인지 검사한 후에 Range 타입의 객체를 반환할 수 있도록 분기 처리하고 있다.

  • A instanceof B 형태로 사용되며
  • A에는 타입을 검사하는 대상 변수, B에는 특정 객체의 생성자가 들어간다.
  • 그 결과값에 따라 truefalse를 반환한다.

4️⃣ ] 객체의 속성이 있는지 없는지에 대한 구분 : in 연산자

객체의 속성이 있는지 확인한 후 true, false를 반환한다.

  • in 연산자B 객체 내부A 속성이 있는지 검사하는 것이기에, A 속성이 undefined를 할당한다고 해서 false를 반환하는게 아니다.
  • delete 연산자를 활용해 객체 내부에서 해당 속성을 제거해야 false를 반환한다.
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} />;
};

NoticeDialog 컴포넌트는 2가지 객체의 유니온 타입인 NoticeDialogPropsprops 받는다.

  • NoticeDialog 컴포넌트가 props로 받는 객체 타입에 따라 렌더링하는 컴포넌트가 달라지도록 하려고 할 때
  • in 연산자로 특정 속성을 가지는지 분기처리하면 된다.

JS에서의 in 연산자 : 런타임의 값만 검사한다.
TS에서의 in 연산자 : 객체 타입에 속성이 존재하는지를 검사한다.

5️⃣ ] 사용자 정의 타입 가드 만들기 : is 연산자

반환 타입이 타입 명제(type predicates)인 함수를 정의하여 사용할 수 있다.

  • 참/거짓의 진릿값을 반환하며 반환타입을 타입 명제로 지정하게 되면
  • 반환 값이 참일 때 A 매개변수의 타입을 B 타입으로 취급하게 된다.

타입 명제 : 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 함수

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

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

  • 함수의 반환 값을 x is DestinationCode로 타이핑해 단순히 boolean을 반환하는 것을 넘는 기능을 한다.
  • 만약 위 함수에서 true를 반환했을 때, 그 스코프 내의 x는 DestinationCode타입임을 확정시킨다.

만약 x is DestinationCode가 아닌, boolean으로 했을 때

const isDestinationCode = (x: string): boolean =>
  destinationCodeList.includes(x);
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;
};

개발자는 if문 내부에서 str타입이 DestinationCode인 것을 알고 있다.

  • 하지만 타입스크립트는 isDestinationCode 내부의 includes 함수를 해석하지 못한다.
  • 따라서 if문 스코프의 str타입을 모르게되고, string만 추론하게 되어 에러가 발생한다.
  • 이처럼 반환값에 대한 타입 정보를 알려주고 싶을 때 is를 사용할 수 있다.

3️⃣ ] 타입 좁히기 - 식별할 수 있는 유니온

종종 태그된 유니온(Tagged Union)으로도 불리는 식별할 수 있는 유니온(Discriminated Unions)은 타입 좁히기에 널리 사용되는 방식이다.

언제 사용하는지, 장점은 무엇인지 알아보자.

1️⃣ ] 에러 정의하기

  • 우아한 형제들에선, 유효성 에러를 사용자들에게 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: "100", errorMessage: "텍스트 에러" },
  { errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000 },
  { errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {} },
];

이로써 다양한 에러 객체를 관리할 수 있게 되었다.

  • 여기에서 타입 에러를 유도시켜보자.
const errorArr: ErrorFeedbackType[] = [
  // ...
  {
  errorCode: "999",
  errorMessage: "잘못된 에러",
  toastShowDuration: 3000,
  onConfirm: () => {},
  }, // expected error
];

ToastErrortoastShowDuration 필드와 AlertErroronConfirm 필드를 모두 가지는 객체를 만들었다.

  • 타입에러를 예상했지만, 놀랍게도 타입 에러를 뱉지 않는다.
  • 자바스크립트는, 덕 타이핑 언어이기 때문이다.
  • 덕 타이핑과 구조적 타이핑 ← 여기에 구조적 타이핑을 정리해뒀다.
  • 따라서 이를 처리하지 않는다면, 무수한 에러 객체가 생길 위험이 있다.

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;
};

판별자로 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: "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’
  },
];

기존 코드와의 차이점은, errorType의 존재밖에 없다. 하지만, 고유한 타입 구분자로 인해 Typescript는 각 객체가 어떤 타입에 속하는 지 명확히 식별할 수 있게 되었다.

  • 기존 errorType이 없던 코드에선, 각 객체가 어떤 타입에 속하는지 명시적으로 구분하지 않았다.
  • 그래서 구조적 타이핑의 영향을 받을 수 밖에 없었고, 에러를 뱉지 않았던 것이다.

3️⃣ ] 식별할 수 있는 유니온의 판별자 선정

이러한 판별자를 선정할때, 조건이 있다.

공식 깃허브의 이슈 탭

  • 리터럴 타입이어야 한다.
  • 판별자로 선정한 값에 적어도 하나 이상의 유닛 타입이 포함되어야 하며, 인스턴스화할 수 있는 타입은 포함되지 않아야 한다.

풀어서 설명하자면,

  • 유닛 타입 : 다른 타입으로 쪼개어 지지 않는, 오직 하나의 정확한 값을 가지는 타입
    • 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’
  }
}

위 예시에서, a만 유일한 유닛타입으로 타입이 좁혀지는 것을 볼 수 있다.

4️⃣ ] Exhaustiveness Checking으로 정확한 타입 분기 유지하기

Exhaustiveness는 사전적으로 철저함, 완전함을 의미한다.

  • 따라서 Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말한다.
  • 타입 좁히기에 사용되는 패러다임 중 하나이다.
  • 예시를 보면서 이해해보자.

1️⃣ ] 상품권

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

이때 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에서 5000일 경우의 분기처리를 진행하지 않았기에 발생한 것이다.
  • 이렇게 모든 케이스에 대한 타입 분기 처리를 하지 않았을 때, 컴파일 타임 에러가 발생하게 하는 것을 Exhaustiveness Checking이라고 한다.
  • 좀 더 살펴보면, exhaustiveCheck에선 never타입으로 선언하고 있다.
    • 즉 매개변수로 그 어떤값도 받지 않음을 선언했으며, 값을 받는다면 에러를 내뱉는다.
    • 조건 처리문의 else에 이 함수를 넣어 모든 타입에 대한 분기 처리를 강제했다.

이처럼 타입에 대한 철저한 분기 처리가 필요하다면, Exhaustiveness Checking을 활용해보길 바란다.

profile
새로운 자극을 주세요.

0개의 댓글