5장 타입 활용하기

5.1 조건부 타입

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

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

type PayMethod<T> = T entends "card" ? Card : Bank;

제네릭 타입으로 Card가 들어오면 Card 타입 아니면 Bank 타입

2. 조건부 타입을 쓰지 않았을 때의 문제점

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(
          (pocket: PocketInfo<Card> | PocketInfo<Bank>) =>
            pocket?.useType === "USE"
        ) ?? [];
      return usablePocketList;
    },
  });
  const result = useCommonQuery<PayMethodType[]>(url, undefined, fetcher);

  return result;
};

각 3가지의 API 엔드포인트가 정해져 있고 공통함수를 통해 최종적으로 필터링된 배열을 반환하는 코드

card, appcard => card
bank => bank

타입 PayMethodType을 Card or Bank로 고정하고 반환값에 PayMethodType[ ] 을 명시
하지만, Card와 Bank를 명확히 구분하는 로직이 없기에 문제가 됨

사용자가 인자로 "card"를 전달했을 때 반환되는 타입이 <card>[] 였으면 좋겠지만 타입 설정이 유니온으로만 되어있기 때문에 구체적으로 추론할 수 없음

즉, 인자로 넣는 타입에 알맞는 타입을 반환하지 못하기 때문에 유니온 외 다른 조치가 필요

3. extends 조건부 타입 활용하여 개선

extends를 활용하여 하나의 API 함수에서 타입에 따라 정확한 반환 타입을 추론하게 만들어보자, 또한 extends를 제네릭의 확장자로 활용하여 3가지 타입이외에 다른 값이 인자로 들어오는 경우도 방어 가능

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

// after
type PayMethodType<T extends "card" | "appcard" | "bank"> = T extends
  | "card"
  | "appcard"
  ? Card
  : Bank;
// before
export const useGetRegisteredList = (
  type: "card" | "appcard" | "bank"
): UseQueryResult<PayMethodType[]> => {
  /* ... */
  const result = useCommonQuery<PayMethodType[]>(url, undefined, fetcher);
  return result;
};

// after
export const useGetRegisteredList = <T extends "card" | "appcard" | "bank">(
  type: T
): UseQueryResult<PayMethodType<T>[]> => {
  /* ... */
  const result = useCommonQuery<PayMethodType<T>[]>(url, undefined, fetcher);
  return result;
};

이런식으로 조건부 타입을 활용하여 원하는 동작인 card / appcard 를 받으면 card를 반환하고 bank를 받으면 bank를 반환

extends 활용 예시 재정리

  • 타입 확장
  • 제네릭과 extends를 함께 사용해 제네릭으로 받는 타입을 제한하는 한정자 역할
    - 개발자가 잘못된 값을 넘기는 휴먼에러를 방지
  • extends를 활용한 조건부 타입 설정
    - 반환 값을 사용자가 원하는 값으로 구체화
    - 불필요한 타입 가드, 타입 단언 방지

4. infer를 활용하여 타입 추론

extends를 사용하여 infer 키워드도 함께 사용하여 extends로 조건을 서술하고 infer로 타입을 추론

type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
// Promise<infer K> : Promise의 반환 값을 추론해 해당 값의 타입을 K라고 지정

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

5.2 템플릿 리터럴 타입 활용

타입스크립트의 유니온 타입 방식을 확장한 방법인 템플릿 리터럴 타입을 지원

// before
type HeaderTag = "h1" | "h2" | "h3" | "h4" | "h5";

// after
type HeadingNumber = 1 | 2 | 3 | 4 | 5;
type HeaderTag = `h${HeadingNumber}`;
// "h1" | "h2" | "h3" | "h4" | "h5"

방향의 경우에도 한 Direction에 다 넣는 것이 아니라 리터럴을 활용하여 당므과 같이 표현 가능

type Vertical = "top" | "bottom";
type Horizon = "left" | "right";

type Direction = Vertical | `${Vertical}${Capitalize<Horizon>}`;

템플릿 리터럴 타입의 장단점

  • 장점 : 더욱 읽기 쉬운 코드 작업 / 코드의 재상요과 수정 용이
  • 단점 : TS 컴파일러는 유니온을 추론하는데 시간이 오래걸리면 타입 추론 안 하고 냅다 에러를 뱉음, 그렇기에 조합의 수가 너무 많지 않게 적절히 나누어서 타입 정의하는 방식을 권장

5.3 커스텀 유틸리티 타입 활용

TS에서 제공하는 유틸리티 타입의 한계를 극복하기 위해 커스텀 유틸리티 타입 제작도 가능

1. 유틸리티 함수 사용을 통한 styled-components 중복 타입 선언 피하기

props으로 받아와 상황에 따라 스타일을 구현하는 경우처럼 props를 styled-components에 전달하려면 타입을 정확하게 작성해야함 이 경우 Pick, Omit 같은 유틸리티 타입 활용

//before
// 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}
    />
  );
};

// after
// style.ts
import { Props } from '../HrComponent.tsx';

// 타입 Props에서 스타일링에 필요한 속성 타입만 골라내 사용 (cf. "className")
type StyledProps = Pick<Props, "height" | "color" | "isFull">;

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

// 이를 통해 중복된 타입 코드를 작성하지 않아도 되고 유지보수를 편하게 할 수 있음

2. PickOne 유틸리티 함수

TS에는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때 타입 검사가 제대로 되지 않는 이슈가 존재
(집합 관점으로 합집합이기 때문에 동시에 모두 넣어도 에러가 발생하지 않음)

해결 방법 1) 식별할 수 있는 유니온(Discriminated Unions)
저번에 배운 식별할 수 있는 유니온을 활용하여 각 타입을 구분할 수 있는 판별자를 넣음

type Card = {
  type: "card"; // 판별자 추가
  card: string;
};
type Account = {
  type: "account"; // 판별자 추가
  account: string;
};
function withdraw(type: Card | Account) {
  /* ... */
}
withdraw({ card: "hyundai", account: "hana" }); 
// 🚨 ERORR : Argument of type '{ card: string; account: string; }' is not assignable to parameter of type 'Card | Account'.

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

하지만 이미 많은 걸 구현한 상태라면 일일이 판별자를 추가해야하므로 불편

해결 방법 2) 커스텀 유틸리티 타입 PickOne 구현하기
해당 문제는 account나 card 속성 하나만 존재하는 객체를 받는 타입을 만드는 것이 목표 (하나의 속성이 들어왔을 때, 다른 타입을 옵셔널한 undefined한 값으로 지정하면 됨)
-> 원치 않은 속성에 값을 넣었을 때 타입 에러가 발생하도록

가장 작은 단위인 oneExcludeOne을 각각 구현한 뒤
두 타입을 활용하여 하나의 타입 PickOne을 표현

// One<T> : 제네릭 타입 T의 1개 키는 값을 가짐
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];

// ExcludeOne<T> : 제네릭 타입 T의 나머지 키는 옵셔널한 undefined 값을 가짐
type ExcludeOne<T> = {
  [P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];
const excludeone: ExcludeOne<Card> =

// 만들어진 PickOne
// PickOne<T> = One<T> + ExcludeOne<T>
type PickOne<T> = One<T> & ExcludeOne<T>;

----------- 실사용 ----------------

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

// 커스텀 유틸리티 타입 PickOne
type PickOne<T> = {
  [P in keyof T]: Record<P, T[P]> &
    Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];

// CardOrAccount가 Card의 속성이나 Account의 속성 중 하나만 가질 수 있게 정의
type CardOrAccount = PickOne<Card & Account>;

function withdraw(type: CardOrAccount) {
  /* ... */
}

withdraw({ card: "hyundai", account: "hana" }); // 🚨 ERROR
withdraw({ card: "hyndai" }); // ✅
withdraw({ card: "hyundai", account: undefined }); // ✅
withdraw({ account: "hana" }); // ✅
withdraw({ card: undefined, account: "hana" }); // ✅
withdraw({ card: undefined, account: undefined }); // 🚨 ERROR

3. NonNullable 타입 검사 함수를 통한 타입 가드

NonNullable 타입

  • 제네릭으로 받는 T가 null 또는 undefined일 때 never 또는 T를 반환하는 타입
  • null이나 undefined가 아닌 경우를 제외하기 위해 사용
type NonNullable<T> = T extends null | undefined ? never : T;

NonNullable 함수

  • 매개변수(value)가 null 또는 undefined일 때 false를 반환하는 함수
  • 반환값이 true라면 null과 undefined가 아닌 다른 타입으로 타입 가드
function NonNullable<T>(value: T): value is NonNullable<T> {
  return value !== null && value !== undefined;
}

5.4 불변 객체 타입으로 활용

상수값을 관리할 때 객체를 많이 사용하는데, 이런 객체를 사용할 때 열린 타입 (any)로 설정할 수 있음
함수 인자를 키로 받을 때 key타입을 string으로 설정하면 getColorHex 함수의 반환값은 any지만
두 가지 방법을 통해 객체 타입을 더욱 정확하고 안전하게 설정 가능

// 방법 1 : as const 키워드로 객체를 불변 객체로 선언
// 방법 2 : keyof 연산자로 함수 인자를 colors 객체에 존재하는 키값만 받도록 설정

const colors = {
  red: "#F45452",
  green: "#0C952A",
  blue: "#1A7CFF",
} as const; // 방법 1 colors 객체를 불변 객체로 선언

const getColorHex = (key: keyof typeof colors) => colors[key];
// 방법 2 colors에 존재하는 키값만 받도록 제어함으로써 getColorHex의 반환값은 string

const redHex = getColorHex("red"); // ✅
const unknownHex = getColorHex("yellow"); // 🚨 ERROR : Argument of type 
// '"yellow"' is not assignable to parameter of type '"red" | "green" | "blue"'.

1. Atom 컴포넌트에서 theme style 객체 활용하기

Atom 단위의 작은 컴포넌트(버튼, 헤더, 인풋 등)은 색상 등의 스타일이 유연해야 하기 때문에 스타일을 props로 많이 받음 theme객체를 두고 관리

const colors = {
  black: "#000000",
  gray: "#222222",
  white: "#FFFFFF",
  mint: "#2AC1BC",
};

const theme = {
  colors: {
    default: colors.gray,
    ...colors,
  },
  backgroundColor: {
    default: colors.white,
    gray: colors.gray,
    mint: colors.mint,
    black: colors.black,
  },
  fontSize: {
    default: "16px",
    small: "14px",
    large: "18px",
  },
};

// 이러한 Theme 객체를 구체화하여 컴포넌트 개선
// 방법 1 : 컴포넌트(ex. Button)에 props로 전달
// 방법 2 : 컴포넌트가 theme 객체에서 값을 가져와 사용

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

// after
// theme 객체 타입 구체화 진행 (typeof + keyof)
type ColorType = typeof keyof theme.colors; // "default" | "black" | "gray" | "white" | "mint"
type BackgroundColorType = typeof keyof theme.backgroundColor; // "default" | "gray" | "mint" | "black"
type FontSizeType = typeof keyof theme.fontSize; // "default" | "small" | "large"

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

위와 같은 예시에서 props로 넘겨줄 때 키 값이 자동완성 되지 않기 때문에 잘못된 키값을 넣어도 에러가 발생하지 않는 문제가 발생하였기 때문에 typeof와 keyof를 사용 해준 것

이렇게 theme 객체 타입을 구체화 하여 string으로 타입을 설정하면 지정된 값만을 받을 수 있어 자동완성과 다른 값을 넣었을 때에 타입오류도 발생하게 할 수 있음

5.5 Record 원시 타입 키 개선

객체 선언시 키가 어떤 값인지 명확하지 않으면 Record의 키를 string이나 number 같은 원시 타입으로 명시 -> 이는 곧 런타임 에러

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

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

겍체가 Category (string)을 키로 사용하기 때문에 키값이 아닌 걸 넣어도 컴파일 오류없이 undefined 이럴 땐 옵셔널 체이닝을 활용하면 됨

// 문제상황 
oodByCategory["양식"]; // Food[]로 추론
console.log(foodByCategory["양식"]); // ? undefined
foodByCategory["양식"].map((food) => console.log(food.name)); // 🚨 runTime ERROR : Cannot read properties of undefined (reading ‘map’)

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

하지만 이 방법은 undefined인 걸 인지하고 있어야하므로 얘도 예상치 못함 런타임 에러 발생 가능

2. 유닛 타입 변경

// before
type Category = string;

// after 냅다 지정
type Category = "한식" | "일식";

하지만 키가 무한해야하므로 그럴 땐 적합하지 않음

3. Partial 활용 정확한 타입 표현

Partial을 활용해 값이 undefined 일 수 있는 상태임을 표현하고
객체 값이 undefined일 수 있는 경우에 Partial을 통해 PartialRecord 타입을 선언

type PartialRecord<K extends string, T> = Partial<Record<K, T>>;

이 이후 객체를 선언할 때 Record 대신에 PartialRecord를 써주어서 됨

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

foodByCategory["양식"]; // Food[]로 추론

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

foodByCategory["양식"]; // Food[] 또는 undefined 타입으로 추론

이렇게 된다면 무한한 키 집합과 없는 키값을 사용하면 컴파일 오류 발생

// before (Record)
foodByCategory["양식"].map((food) => console.log(food.name)); 
// 🚨 runTime ERROR : Cannot read properties of undefined (reading ‘map’)

// after (PartialRecord)
foodByCategory["양식"].map((food) => console.log(food.name));
// 🚨 ERROR : Object is possibly 'undefined'

// 물론 컴파일 오류를 확인하여 옵셔널 체이닝을 사용해서 사전 조치를 할 수 있음
foodByCategory["양식"]?.map((food) => console.log(food.name));
profile
기록, 꺼내 쓸 수 있는 즐거움

0개의 댓글