ts 책 스터디(타입 활용하기)

MountionRiver·2025년 7월 22일

조건부 타입

  • 조건부 타입을 활용하면 중복되는 타입 코드를 제거하고 상황에 따라 적절한 타입을 얻을 수 있기 때문에 더욱 정확한 타입을 추론 할 수 있게 된다.

extends와 제네릭을 활용한 조건부 타입

  • extends 키워드는 타입을 확장할 때와 타입을 조건부로 설정할 때 사용되며, 제네릭 타입에서는 한정자 역할로도 사용된다.

    interface Bank {
      financialCode: string;
      companyName: string;
      name: string;
      fullName: string;
    }
    
    interface Card {
      financialCode: string;
      companyName: string;
      name: string;
      appCardType?: string;
    }
    
    type PayMethod<T>= T extends "card" ? Card : Bank;
    type CardPayMethodType = PayMethod<"card">;
    type BankPayMethodType = PayMethod<"bank">;

PayMethod 타입은 제네릭 타입으로 extends를 사용한 조건부 타입이다.
제네릭 매개변수에 "card"가 들어오면 Card 타입, 그 외의 값이 들어오면 Account 타입으로 결정된다. PayMethod를 사용해서 CardPayMlethodType, BankPayMethodType을 도출할 수 있다.

조건부 타입을 사용하지 않았을때의 문제점

type PayMethodType = PayMethodInfo<Card> | PayMethodInfo<Bank>;

export const useGetRegisteredList = (
  type: "card" | "appcard" | "bank"
): UseQueryResult<PayMethodType[]> => {
  const url = `baeminpay/codes/${type === "appcard" ? "card" : type}`;

  const fetcher = fetcherFactory<PayMethodType[]>({
    onSuccess: (res) => {
      const usablePocketList =
        res?.filter (
          // 이 부분에서 type PayMethodType = PayMethodInfo<Card> | PayMethodInfo<Bank>;으로 추론 됨
          (pocket: PocketInfo<Card> | PocketInfo<Bank>) => 
            pocket?.useType === "USE"
        ) ?? []
      return usablePocketList;
    },
  });

  const result = useCommonQuery<PayMethodType[]>(url, undefined, fetcher);

  return result;
};

useGetRegisteredList 함수는 타입으로 "card", "appcard", "bank"를 받아서 해당 결제 수단의 결제 수단 정보 리스트를 반환하는 함수이다.이때 useGetRegisteredList 함 수가 반환하는 Data 타입은 (PocketInfo<Card> | PocketInfo<Bank>)[]이다. 사용자가 타입으로 "card"를 넣었을 때, useGetRegisteredList 함수가 반환하는 Data 타입은 PocketInfo<Card>[]라고 유추할 수 있다. 하지만 useGetRegisteredList 함수가 반환하는 Data 타입은 PayMethodType이기 때문에 사용하는 쪽에서는 PocketInfo<Bank>[]일 가능성도 있다.
타입 설정이 유니온으로만 되어있기 때문에 타입스크립트는 해당 타입에 맞는 Data 타입을 추론할 수 없다. 이처럼 인자에 따라 반환되는 타입을 다르게 설정하고 싶다면 extends를 사용한 조건부 타입 을 활용하면 된다.

extends 조건부 타입을 활용하여 계산하기

  • useGetRegisteredList 함수의 반환 Data는 인자 타입에 따라 정해져 있다. 타입으로
    "card" 또는 "appcard"를 받으면 카드 결제 수단 정보 타입인 PocketInfoc<Card>를 반환 하고, "bank"를 받는다면 PocketInfo<Bank>를 반환한다.
    type: "card" | "appcard" => PocketInfo<Card>type: "bank" => PocketInfo‹Bank>
    계좌와 카드의 API 함수를 각각 만들 수도 있지만 엔드포인트의 마지막 경로만 다르고 계좌 와 카드가 같은 컴포넌트에서 사용되기 때문에 하나의 함수에서 한 번에 관리해야 하는 상황 이라고 가정해보자. 이러한 상황에서 조건부 타입을 활용하면 하나의 API 함수에서 타입에 따른 정확한 반환 타입을 추론하게 만들 수 있다.
    조건부 타입을 사용해서 PayMethodInfoc<Card> | PayMethodInfo<Bank> 타입이었던
    PayMethodType 타입을 개선해보자.
    type PayMethodType<T extends "card" | "appcard" | "bank"> = T extends
      | "card"
      | "appcard"
      ? Card
      : Bank;
  • PayMethodType의 제네릭으로 받은 값이 "card" 또는 "appcard"일 때는 PayMethodInfo 타입을, 아닐 때는 PayMethodInfo 타입을 반환하도록 수정했다. 또한 결제 수단 타입에는 "card", "appcard", "bank"만 들어올 수 있기 때문에 extends를 한정자 로 활용해서 제네릭에 넘겨오는 값을 제한하도록 했다.
    새롭게 정의한 PayMethodType 타입에 제네릭 값을 넣어주기 위해서는 useGetRegiteredList 함수 인자의 타입을 넣어줘야 한다. useGetRegisteredList 인자 타입을 제네릭으로 받으면서 extends를 활용하여 "card", "appcard", "bank" 이외에 다른 값이 인자로 들어올 경우에는 타입 에러를 반환하도록 구현해보자.
export const useGetRegisteredList = <T extends 'card' | 'appcard' | 'bank'>(
  type: T
): UseQueryResult<PayMethodType<T>[]> => {
  const url = `baeminpay/codes/${type === 'appcard' ? 'card' : type}`;

  const fetcher = fetcherFactory<PayMethodType<T>[]>({
    onSuccess: (res) => {
      const usablePocketList =
        res?.filter(
          (pocket: PocketInfo<Card> | PocketInfo<Bank>) =>
            pocket?.useType === 'USE'
        ) ?? [];
      return usablePocketList;
    },
  });

  const result = useCommonQuery<PayMethodType<T>[]>(url, undefined, fetcher);

  return result;
};
  1. 제네릭과 extends를 함께 사용해 제네릭으로 받는 타입을 제한했다. 따라서 개발자는 잘못된 값을 넘길 수 없기 때문에 휴먼 에러를 방지할 수 있다.
  2. extends를 활용해 조건부 타입을 설정했다. 조건부 타입을 사용해서 반환 값을 사용자가 원하는 값으로 구 체화할 수 있었다. 이에 따라 불필요한 타입 가드, 타입 단언 등을 방지할 수 있다.

infer를 활용해서 타입 추론하기

  • extends를 사용 할 때 infer 키워드를 사용할 수 있다. extends로 조건을 서술하고 infer로 타입을 추론하는 방식을 사용한다.
type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
// UnpackPromise 타입은 제네릭을 T로 받아 T가 Promise로  래핑된 상태면 K를 반환하고 아닐경우 any를 반환
// Promise<infer K>는 Promise의 반환값을 추론해 해당값의 타입을 K로 한다는것

const promises = [Promise.resolve('Mark'), Promise.resolve(38)];
type Expected = UnpackPromise<typeof promises>; // string | number

RouteBaseRouteItem은 라이더 어드민에서 라우팅을 위해 사용하는 타입이다.
라우팅은 페이지 이동 또는 특정 경로 요청을 처리하는 방법을 정의하는 것이다.

interface RouteBase {
  name: string;
  path: string;
  component: ComponentType;
}

export interface RouteItem {
  name: string;
  path: string;
  component?: ComponentType;
  pages?: RouteBase[];
}

routes는 위 타입을 기반으로 구성된 배열로, 권한 API로부터 받은 사용자 권한과 각 라우트의 name 값을 비교하여, 인가되지 않은 사용자의 접근을 방지하는 데 사용된다.

export interface SubMenu {
  name: string;
  path: string;
}

export interface MainMenu {
  name: string;
  path?: string;
  subMenus?: SubMenu[];
}

export type MenuItem = MainMenu | SubMenu;
export const menuList: MenuItem[] = [
  {
    name: "계정 관리",
    subMenus: [
      { name: "기기 내역 관리", path: "/device-history" },
      { name: "헬멧 인증 관리", path: "/helmet-certification" },
    ],
  },
  {
    name: "운행 관리",
    path: "/operation",
  },
  // ...
];
  • menuList에는 MainMenuSubMenu가 혼합될 수 있기 때문에 유니온 타입(MenuItem)으로 정의됨.
  • 문제는 name 필드가 단순 string이기 때문에 routesmenuList 내에서 name이 달라도 컴파일 타임에 에러가 발생하지 않는다는 점이다.
  • 이로 인해 실제 권한 이름과 다른 문자열이 들어가도 타입 시스템은 감지하지 못함.

좀더 자세한 설명

const menuList = [
  {
    name: "계정 관리",
    subMenus: [
      { name: "기기 내역 관리", path: "/device-history" },
      { name: "헬멧 인증 관리", path: "/helmet-certification" },
    ]
  },
  {
    name: "운행 관리",
    path: "/operation"
  }
]

현재 구조에는 name이 2군데 존재함 MainMenuSubMenu

Route를 만들 때 MainMenuSubMenu에 존재하는 것만 routes에 넣고 싶음. SubMenu우선

const routes = [
  {
    name: "기기 내역 관리", // 메뉴에 존재함
    ...
  },
  {
    name: "회원 정보 조회", // 메뉴에 없음 → 단순 "string"이라서 타입 검사에서 안 잡힘
  }
]
export const menuList = [
  {
    name: "계정 관리",
    subMenus: [
      { name: "기기 내역 관리", path: "/device-history" },
      { name: "헬멧 인증 관리", path: "/helmet-certification" },
    ],
  },
  {
    name: "운행 관리",
    path: "/operation",
  },
] as const;

조건부 타입으로 name 값 추출

type UnpackMenuNames<T extends ReadonlyArray<MenuItem>> = 
  T extends ReadonlyArray<infer U>       // T는 배열이고 내부 아이템 하나를 U로 추론
    ? U extends MainMenu                 // 그 아이템이 MainMenu이면
      ? U['subMenus'] extends infer V   // subMenus를 V로 추론하고
        ? V extends ReadonlyArray<SubMenu>  // 그 V가 SubMenu 배열이면
          ? UnpackMenuNames<V>              // 재귀적으로 다시 처리
          : U['name']                       // 아니라면 MainMenu 자체의 name 사용
        : never
      : U extends SubMenu               // U가 MainMenu가 아니고 SubMenu면
      ? U['name']                       // SubMenu의 name 사용
      : never
    : never;

템플릿 리터럴 타입 활용하기

  • 타입스크립트는 유니온 타입을 이용하여 특정 문자열을 타입으로 지정가능하다. 여기에 템플릿 리터럴을 적용하여 특정 문자열에 대한 타입을 선언할 수 있다.
  type HeadingNumber = 1 | 2 | 3 | 4 | 5;
  type HeaderTag = `h${HeadingNumber}`;  // "h1" | "h2" | "h3" | "h4" | "h5"

주의할 점

타입스크립트 컴파일러가 유니온을 추론할때 시간이 오래걸리면 타입을 추론하지 않고 에러를 밷을 경우가 있음. 유니온의 조합의수가 많지 않게 적절히 나누어 정의필요.

커스텀 유틸리티 타입 활용하기

  • 타입스크립트의 유틸리티 타입으로 타입정의가 어려울 경우 커스텀 유틸리티 타입을 제작해 사용 가능하다.

유틸리티 함수를 활용해 styled-components의 중복 타입 선언 피하기

// HrComponent.tsx
export type Props = {
  height?: string;
  color?: keyof typeof colors;
  isFull?: boolean;
  className?: string;
  ...
};

export const Hr: VFC<Props> = ({ height, color, isFull, className }) => {
  return (
    <HrComponent
      height={height}
      color={color}
      isFull={isFull}
      className={className}
    />
  );
};

// style.ts
import { Props } from "...";

type StyledProps = Pick<Props, "height" | "color" | "isFull">;

const HrComponent = styled.hr<StyledProps>`
  height: ${({ height }) => height || "10px"};
  margin: 0;
  background-color: ${({ color }) => colors[color] || colors["gray7"]};
  border: none;

  ${({ isFull }) =>
    isFull &&
    css`
      margin: 0 -15px;
    `}
`;

Hr 컴포넌트는 다음과 같은 props를 받는다:

  • height: 수평선 높이
  • color: 색상 (colors 객체의 key)
  • isFull: 좌우 여백 없이 전체 너비로 표시
  • className: 스타일링용 클래스

이 중 height, color, isFullstyled-componentsHrComponent로 그대로 전달되며, 타입도 동일하다.

처음엔 이처럼 타입을 따로 선언할 수 있다:

type StyledProps = {
  height?: string;
  color?: keyof typeof colors;
  isFull?: boolean;
};

하지만 이 방식은 Props와 중복 선언이 많고, 하나면 변경이 필요할 경우 타입 불일치 발생 가능성이 생긴다.
아래처럼 Pick같은 유틸리티 타입을 사용하면 중복 선언을 피할 수 있다.

type StyledProps = Pick<Props, "height" | "color" | "isFull">;

PickOne 유틸리티 함수

TypeScript에서는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때, 타입 검사가 느슨해지는 문제가 존재.

예를 들어, 아래와 같이 CardAccount 중 하나만 받기를 의도했지만:

type Card = { card: string };
type Account = { account: string };

function withdraw(type: Card | Account) {
  // ...
}

withdraw({ card: "hyundai", account: "hana" });  // 오류발생 x

cardaccount를 모두 가진 객체도 합집합의 범주에 들어가기 때문에 에러 발생 x

식별할 수 있는 유니온으로 객체 타입을 유니온으로 받기

공통된 type 속성을 추가하여 유니온을 명확히 구분할 수 있다:

type Card = { type: "card"; card: string };
type Account = { type: "account"; account: string };

function withdraw(type: Card | Account) {
// ...  
}

withdraw({ type: "card", card: "hyundai" });
withdraw({ type: "account", account: "hana" });

이 방식은 안전하지만 기존 타입에 type 속성을 추가해야 하고,
이를 사용하는 모든 코드도 수정해야 하므로 번거롭다. 수정하지 않은 부분이 발생한다면 또 다른 문제 발생

PickOne<T> 커스텀 유틸리티 타입 구현하기

하나의 속성만 필수로 받고, 나머지 속성은 undefined로 제한해 "둘 중 하나만 가질 수 있도록" 타입을 정의한다.

type Card = { card: string };
type Account = { account: string };

type CardOrAccount =
  | { card: string; account?: undefined }
  | { card?: undefined; account: string };

type PayMethod =
  | { account: string; card?: undefined; payMoney?: undefined }
  | { account: undefined; card?: string; payMoney?: undefined }
  | { account: undefined; card?: undefined; payMoney?: string };

이 구조는 cardaccount 중 하나만 값이 존재하도록 강제한다.
이제 이걸 커스텀 유틸리티 타입으로 구현 해보자.

type PickOne<T> = {
  [P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];

PickOne 살펴보기

앞서 만든 PickOne 유틸리티 타입을 하나씩 자세히 뜯어보자. 먼저 PickOne 타입을 2가지 타입으로 분리해서 생각할 수 있다.

이때 T는 객체 타입이라고 가정한다.


  • One<T>
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
  1. [P in keyof T]에서 T는 객체이므로 PT객체의 키 값이다.
  2. Record<P, T[P]>는 키 P에 해당하는 값을 가지고, valueP를 키로둔 T 객체의 값의 레코드 타입을 말한다.
  3. 따라서 { [P in keyof T]: Record<P, T[P]> }에서 키는 T객체의 키 모음이고, value는 해당 키의 원본객체 T를 말한다
  4. 3번의 타입에서 다시 [keyof T]의 키값으로 접근하기 때문에 최종 결과는 전달받은 T와 같다.
type Card = { card: string };
const one: One<Card> = { card: "hyundai" }; // OK

  • ExcludeOne<T>
type ExcludeOne<T> = {
  [P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];
  1. [P in keyof T]에서 P는 객체의 각 키다.
  2. Exclude<keyof T, P>는 전체 키 중 P만 제외한 나머지를 의미한다.
    예: T = { a, b }P = "a"일 때 → Exclude<"a" | "b", "a"> = "b"
  3. Record<"b", undefined>{ b: undefined }가 된다.
  4. Partial을 씌우면 { b?: undefined }가 된다.
  5. 전체적으로 하나의 속성을 제외한 나머지 속성들은 undefined 값만 허용하도록 만든다.

  • PickOne<T>
type PickOne<T> = One<T> & ExcludeOne<T>

One & ExcludeOne는 [P in keyof T]를 공통으로 갖기 때문에 아래같이 교차된다.

type PickOne<T> = {
  [P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];

이 타입을 해석하면 T타입의 1개의 키는 값을 가지고 있으며, 나머지 키는 옵셔널한 undefined값을 가진 객체를 의미한다.

type Card = { card: string };
type Account = { account: string };

const pickOne1: PickOne<Card & Account> = { card: "hyundai" }; // (O)
const pickOne2: PickOne<Card & Account> = { account: "hana" }; // (O)
const pickOne3: PickOne<Card & Account> = { card: "hyundai", account: undefined }; // (O)
const pickOne4: PickOne<Card & Account> = { card: undefined, account: "hana" }; // (O)
const pickOne5: PickOne<Card & Account> = { card: "hyundai", account: "hana" }; // (X)

PickOne 타입 적용하기

type Card = { card: string };
type Account = { account: string };
type CardOrAccount = PickOne<Card & Account>;
function widthdraw(type: CardOrAccount) {
  //....
}
const d: CardOrAccount = { card: "hyundai", account: "hana" }; // 에러발생

NonNullable 타입 검사 함수를 사용하여 간편하게 타입 가드하기

null을 가질 수 있는값의 null 처리는 자주 사용되는 타입 가드 패턴 중 하나이다.
일반적으로 if 문을 사용해서 null 처리 타입가드를 적용하지만, is 키워드와 NonNullable 타입으로 타입 검사를 위한 유틸리티 함수를 만들어 사용할 수도 있다.


NonNullable 타입이란?

  • 타입스크립트에서 제공하는 유틸리티 타입
  • 제네릭으로 받는 Tnull 또는 undefined일 때 never를, 그렇지 않으면 T를 반환하는 타입이다.
type NonNullable<T> = T extends null | undefined ? never : T;

null, undefined를 검사해주는 NonNullable 함수

NonNullable 함수는 매개변수인 valuenull 또는 undefined이면 false를 반환한다. true가 반환된다는 것은 valuenull이나 undefined가 아님을 의미하며, 타입이 좁혀지게 된다.

function NonNullable<T>(value: T): value is NonNullable<T> {
  return value !== null && value !== undefined;
}

Promise.all 사용할 때 NonNullable 적용하기

Promise.all을 사용할 때 NonNullable을 적용한 예시를 살펴보자.

class AdCampaignAPI {
  static async operating(shopNo: number): Promise<AdCampaign | null> {
    try {
      return await fetch(`/ad/shopNumber=${shopNo}`);
    } catch (error) {
      return null;
    }
  }
}
  
const shopList = [
  { shopNo: 100, category: "chicken" },
  { shopNo: 101, category: "pizza" },
  { shopNo: 102, category: "noodle" },
];

const shopAdCampaignList = await Promise.all(
  shopList.map((shop) => AdCampaignAPI.operating(shop.shopNo))
);
  
const shopAds = shopAdCampaignList.filter(NonNullable);

AdCampaignAPI.operating 함수에서 null을 반환할 수 있기 때문에
shopAdCampaignList의 타입은 Array<AdCampaign | null>로 추론된다.
shopAdCampaignList를 순회할 때 (예: map, forEach 등) 매번 콜백 함수 내에서 if 문으로 타입 가드를 반복적으로 작성해야 한다.
하지만 NonNullable을 사용해서 shopAdCampaignList를 필터링하면 shopAdsArray<AdCampaign> 타입으로 정확히 추론된다:

불변 객체 타입으로 활용하기

as const 키워드로 객체를 불변 객체로 선언하고,
keyof 연산자를 사용하여 존재하는 키 값만 받도록 설정할 수 있다.

keyof, as const로 객체 타입을 구체적으로 설정하면 타입에 맞지 않는 값을 전달할 경우 타입 에러가 발생하게 되어 컴파일 단계에서 발생할 수 있는 실수를 방지할 수 있다. 또한 자동 완성 기능을 통해 객체에 어떤 값이 있는지 쉽게 파악할 수 있게 된다.

Atom 컴포넌트에서 theme 스타일 객체 활용하기

Atom 단위의 작은 컴포넌트(Button, Header, Input 등)는
폰트 크기, 폰트 색상, 배경 색상 등 다양한 환경에서 유연하게 사용될 수 있도록 구현되어야 한다.

interface Props {
  fontSize?: string;
  backgroundColor?: string;
  color?: string;
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
}

const Button: FC<Props> = ({ fontSize, backgroundColor, color, children }) => {
  return (
    <ButtonWrap fontSize={fontSize} backgroundColor={backgroundColor} color={color}>
      {children}
    </ButtonWrap>
  );
};

const ButtonWrap = styled.button<Omit<Props, "onClick">>`
  color: ${({ color }) => theme.color[color ?? "default"]};
  background-color: ${({ backgroundColor }) => theme.bgColor[backgroundColor ?? "default"]};
  font-size: ${({ fontSize }) => theme.fontSize[fontSize ?? "default"]};
`;

앞의 코드에서 fontSize, backgroundColor 같은 props 타입이 string이면
Button 컴포넌트의 props로 color, backgroundColor를 넘겨줄 때
키 값이 자동 완성되지 않으며, 잘못된 키 값을 넣어도 에러가 발생하지 않는다.

이러한 문제는 theme 객체로 타입을 구체화하여 해결할 수 있다.

keyof 연산자로 객체 키 값을 타입으로 추출하기

TypeScript에서 keyof 연산자는 객체 타입을 받아
해당 객체의 키 값을 string 또는 number 리터럴 유니온 타입으로 반환한다. 객체 타입으로 인덱스 시그니쳐가 사용되었다면 keyof는 인덱스 시그니쳐의 키 타입을 반환한다.

interface ColorType {
  red: string;
  green: string;
  blue: string;
}

type ColorKeyType = keyof ColorType; // 'red' | 'green' | 'blue'

typeof 연산자로 값을 타입으로 다루기

keyof 연산자는 타입을 입력받기 때문에, 값으로 선언된 객체를 타입으로 다루려면
TypeScript의 typeof 연산자를 활용해야 한다.

JavaScript에서 typeof는 값의 타입을 확인하기 위한 연산자지만,
TypeScript에서는 변수나 속성의 타입을 추론하는 역할을 한다. 단독으로 사용하기 보다는 ReturnType 같이 유틸리티 타입이나 keyof 연산자같이 타이을 받는 연산자와 함께 쓰인다.

const colors = {
  red: "#F45452",
  green: "#0C952A",
  blue: "#1A7CFF",
};

type ColorsType = typeof colors;
/*
{
  red: string;
  green: string;
  blue: string;
}
*/

객체의 타입을 활용해서 컴포넌트 구현

color, backgroundColor, fontsize의 타입을 theme 객체에서 추출하고 해당 타입을 Button 컴포넌트에 사용.

import { FC } from "react";
import styled from "styled-components";
const colors = {
  black: "#000000",
  gray: "#222222",
  white: "#FFFFFF",
  mint: "#2AC1BC",
};

const theme = {
  color: {
    default: colors.gray,
    ...colors,
  },
  bgColor: {
    default: colors.white,
    gray: colors.gray,
    mint: colors.mint,
    black: colors.black,
  },
  fontSize: {
    default: "16px",
    small: "14px",
    large: "18px",
  },
};
  
// 타입 정의

type ColorType = keyof typeof theme.color;
type BackgroundColorType = keyof typeof theme.bgColor;
type FontSizeType = keyof typeof theme.fontSize;

interface Props {
  color?: ColorType;
  backgroundColor?: BackgroundColorType;
  fontSize?: FontSizeType;
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
}

// 컴포넌트:

const Button: FC<Props> = ({ fontSize, backgroundColor, color, children }) => {
  return (
    <ButtonWrap fontSize={fontSize} backgroundColor={backgroundColor} color={color}>
      {children}
    </ButtonWrap>
  );
};

const ButtonWrap = styled.button<Omit<Props, "onClick">>`
  color: ${({ color }) => theme.color[color ?? "default"]};
  background-color: ${({ backgroundColor }) => theme.bgColor[backgroundColor ?? "default"]};
  font-size: ${({ fontSize }) => theme.fontSize[fontSize ?? "default"]};
`;

const TestComponent = () => {
  return <Button backgroundColor="black" />;
};

이처럼 theme뿐 아니라 여러 상수 값을 인자나 props로 받은 다음,
객체의 키 값을 추출한 타입을 활용하면
해당 객체에 접근할 때 TypeScript의 도움을 받아 실수를 방지할 수 있다.

Record 원시 타입 키 개선하기

객체를 선언할 때 키가 어떤 값인지 명확하지 않으면, Record의 키를 string이나 number 같은 원시 타입으로 명시하는 경우가 많다. 하지만 이때 타입스크립트는 키가 실제로 유효하지 않더라도 오류를 표시하지 않는다. 이로 인해 예상치 못한 런타임 에러를 야기 할 수 있다.Record를 명시적으로 사용하는 방법에 대해 알아보자.

무한한 키를 집합으로 가지는 Record

type Category = string;

interface Food {
  name: string;
  // ...
}

const foodByCategory: Record<Category, Food[]> = {
  한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
  일식: [{ name: "초밥" }, { name: "텐동" }],
};

여기서 Category의 타입은 string이다. 이처럼 CategoryRecord의 키로 사용하면
foodByCategory 객체는 무한한 키 집합을 갖게 된다.

이 경우 존재하지 않는 키를 사용하더라도 TypeScript는 오류를 표시하지 않는다:

foodByCategory["양식"]; // 타입 추론: Food[]
foodByCategory["양식"].map((food) => console.log(food.name)); // 오류 없음

그러나 실제 런타임에서는 foodByCategory["양식"]undefined가 되어 오류가 발생한다:

foodByCategory["양식"].map((food) => console.log(food.name)); 
// ❌ Uncaught TypeError: Cannot read properties of undefined (reading 'map')

이 때 자바스크립트의 옵셔널 체이닝을 사용하면 런타임 오류를 방지할 수 있다.
옵셔널 체이닝은 객체의 속성 접근 시 중간에 null 또는 undefined가 있어도 오류 없이 안전하게 접근할 수 있도록 해준다.

foodByCategory["양식"]?.map((food) => console.log(food.name));

그러나 어떤 값이 undefined일 수 있는지를 매번 판단해야 하는 번거로움이 생긴다.
또한 실수로 undefined일 수 있는 값을 인지하지 못하고 코드를 작성하면 예상치 못한 런타임 오류가 발생할 수 있다.

유닛 타입으로 변경하기

  • 키가 유한한 집합이라면 유닛 타입(쪼개지지 않고 하나의 정확한 값을 가지는 타입)을 사용할 수 있다.
type Category = "한식" | "일식";
interface Food {
  name: string;
  // ...
}
const foodByCategory: Record<Category, Food[]> = {
  한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
  일식: [{ name: "초밥" }, { name: "텐동" }],
};

// Property ‘양식’ does not exist on type ‘Record<Category, Food[]>’.
foodByCategory["양식"];

Partial을 활용하여 정확한 타입 표현하기

  • 키가 무한한 상태에서 Partial을 사용하여 해당 값이 undefined 일 수 있는 상태임을 표현 가능. 객체 값이 undefined일 수 있는 경우에 Partial을 사용해서 PartialRecord 타입을 선언하고 객체를 선언할 때 이것을 선언 할 수 있다.
    type PartialRecord<K extends string, T> = Partial<Record<K,T>>;
    type Category = string;
    
    interface Food {
      name: string;
      // ...
    }
    
    const foodByCategory: PartialRecord<Category, Food[]> = {
      한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
      일식: [{ name: "초밥" }, { name: "텐동" }],
    };
    
    foodByCategory["양식"]; // Food[] 또는 undefined 타입으로 추론
    foodByCategory["양식"].map((food) => console.log(food.name)); // Object is possibly 'undefined'
    foodByCategory["양식"]?.map((food) => console.log(food.name)); // OK

0개의 댓글