
목차우아한형제들의 타입스크립트의 실전 활용 예제 알아보기
React, react-query, styled-components와 함께 활용하는 예제 알아보기
타입스크립트의 조건부 타입은 자바스크립트의 삼항 연산자와 동일한 형태를 가진다.
Condition ? A : B;
// Condition이 true일 때 A 타입
// Condition이 false일 때 B 타입
조건부 타입을 활용하면 얻을 수 있는 장점
아래는 어떤 상황에서 조건부 타입이 필요한지, 조건부 타입을 적용함으로써 어떤 장점을 얻을 수 있는지 알아본다.
타입스크립트에서 다양한 상황에서 활용되는 extends
T extends U ? X : Y
// 타입 T를 U에 할당할 수 있으면 X 타입
// 타입 T를 U에 할당할 수 없으면 Y 타입
interface Bank {
financialCode: string;
fullName: string; // Card와의 차이
}
interface Card {
financialCode: string;
appCardType?: string; // Bank와의 차이
}
type payMethod<T> = T extends "card" ? Card : Bank; // 제네릭 타입으로 extends를 사용한 조건부 타입
type CardPayMethodType = PayMethod<"card">; // 제네릭 매개변수 = "card" : Card 타입
type BankPayMethodType = PayMethod<"bank">; // 제네릭 매개변수 != "card" : Bank 타입
📣 (2)(3)챕터의 경우 책의 설명만으로 충분치 않아 ChatGPT의 도움을 받아 새로 이해한 내용을 토대로 정리했습니다.
책과 예시 코드는 동일하지만 과정에 대한 설명은 다름을 명시합니다.
React-Query + TypeScript 프로젝트의 일부인 아래 코드를 보면서 문제를 파악해보자.
계좌(bank), 카드(card), 앱카드(appcard) 3가지 결제 수단이 있다. 아래 코드는 주어진 결제 수단 타입(type)을 서버 응답을 처리하는 공통 함수(useGetRegisteredList)에 전달. 각 API를 통해 결제 수단 정보를 배열로 받아와 최종적으로 필터링된 배열(result)을 반환하는 코드다.
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;
};
위 코드를 만든 개발자는 useGetRegisteredList가 인자의 타입으로 "card", "appcard", "bank" 중 하나를 받아 해당 타입과 알맞은 타입으로 반환까지 해내기를 원했다.
CardBank때문에 타입 PayMethodType을 Card 또는 Bank 타입의 PatMethodInfo 중 하나로 고정하고 반환값 result에 PayMethodType[] 타입을 명시해주었다. 하지만 Card와 Bank 를 명확히 구분하는 로직이 없다. 이것이 문제가 된다.
사용자가 인자로 "card"를 전달했을 때 함수가 반환하는 타입이 PayMethodInfo<Card>[]였으면 좋겠지만, 타입 설정이 유니온(|)으로만 되어있기 때문에 구체적으로 추론할 수 없다.
즉, useGetRegisteredList는 인자로 넣는 타입에 알맞은 타입을 반환하지 못하는 함수다. 유니온 외 다른 조치가 필요하다.
extends 조건부 타입을 활용하여 하나의 API 함수에서 타입에 따라 정확한 반환 타입을 추론하게 만들 수 있다. 또한 extends를 제네릭의 확장자로 활용해서 "card", "appcard", "bank" 외 다른 값이 인자로 들어오는 경우도 방어한다.
// before
type PayMethodType = PayMethodInfo<Card> | PayMethodInfo<Bank>;
// after
type PayMethodType<T extends "card" | "appcard" | "bank"> = T extends
| "card"
| "appcard"
? Card
: Bank;
// PayMethodType의 제네릭으로 받은 값이 "card" 또는 "appcard"면 PayMethodInfo<Card> 타입을 반환
// PayMethodType의 제네릭으로 받은 값이 이외의 값이면 PayMethodInfo<Bank> 타입을 반환
// +) "card" 앞 | 는 줄바꿈 구분자로 이해하면 편함. 한줄로 명확하게 표현하면 아래와 같음
// T extends ("card" | "appcard") ? Card : Bank;
새롭게 정의한 PayMethodType에 제네릭 값을 넣어주기 위해 useGetRegisteredList 함수 인자의 타입을 넣어준다.
// 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;
};
이렇게 조건부 타입을 활용함으로써
PayMethodInfo<Card>를 반환하고PayMethodInfo<Bank>를 반환한다.이에 따라 불필요한 타입 가드와 불필요한 타입 단언을 하지 않아도 된다.
📣 extends 활용 예시 재정리
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
타입스크립트에서는 유니온 타입을 사용해서 변수 타입을 특정 문자열로 지정할 수 있었다. 이 방식은 휴먼 에러 방지 및 자동 완성 기능을 통한 개발 생산성 향상 등의 장점을 가진다.
type HeaderTag = "h1" | "h2" | "h3" | "h4" | "h5";
타입스크립트 4.1부터 이를 확장하는 방법인 템플릿 리티럴 타입(Template Literal Type) 을 지원한다.
type HeadingNumber = 1 | 2 | 3 | 4 | 5;
type HeaderTag = `h${HeadingNumber}`;
// "h1" | "h2" | "h3" | "h4" | "h5"
type Vertical = "top" | "bottom";
type Horizon = "left" | "right";
type Direction = Vertical | `${Vertical}${Capitalize<Horizon>}`;
// "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight"
템플릿 리터럴 타입의 장점
템플릿 리터럴 타입 사용 시 주의할 점
타입스크립트에서 제공하는 유틸리티 타입만으로 표현하기에는 한계가 있는 경우 커스텀 유틸리티 타입을 제작해서 사용한다.
컴포넌트의 background-color, size 값 등을 props로 받아와서 상황에 따라 스타일을 구현하는 경우와 같이, 스타일 관련 props를 styled-components에 전달하려면 타입을 정확하게 작성해줘야 한다.
이 경우 타입스크립트에서 제공하는 Pick, Omit 과 같은 유틸리티 타입을 활용한다.
// 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}
/>
);
};
위 예제에서 HrComponent는 styled-component인데 props로 height, color, isFull을 스타일링에 활용하려 한다.
Hr에 사용된 타입 Props 중에서도 일부만을 필요로 하는 상황이니 Pick을 이용해 필요한 타입만 골라 StyledProps로 새로 정의해 사용하면 아래의 형태가 된다.
중복된 타입 코드를 작성하지도 않아도 되고 유지보수를 편하게 할 수 있다.
// 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개 이상의 객체를 유니온 타입으로 받을 때 타입 검사가 제대로 되지 않는 이슈가 있다.
type Card = {
card: string;
};
type Account = {
account: string;
};
function withdraw(type: Card | Account) {
/* ... */
}
withdraw({ card: "hyundai", account: "hana" });
// Card와 Account 속성을 한 번에 받아도 에러 없음
위 예제에서 Card, Account는 집합 관점에서 합집합이기 때문에 withdraw 함수의 인자는 { card: "hyundai" }와 { account: "hana" }를 모두 받아도 타입 에러가 발생하지 않는다.
해결방법 1번) 식별할 수 있는 유니온(Discriminated Unions) 기법 활용
각 타입을 구분할 판별자로 type이라는 속성을 추가해 withdraw 함수를 사용하는 곳에서 정확한 타입을 추론할 수 있도록 한다.
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 구현하기
1번의 문제가 발생하지 않는 다른 방법은 커스텀 유틸리티 타입을 만들어내는 것이다.
위 위제에서는 account일 때 card를 받지 못하고, card일 때 acount를 받지 못하게 하는 것, account 또는 card 속성 하나만 존재하는 객체를 받는 타입을 만드는 것이 목표다. 즉, 하나의 속성이 들어왔을 때 다른 타입을 옵셔널한 undefined 값으로 지정하는 방법을 사용할 수 있다. 이 경우 사용자가 의도적으로 undefined 값을 넣지 않는 이상, 원치 않은 속성에 값을 넣었을 때 타입 에러가 발생한다.
해당 커스텀 유틸리티 타입을 만들기 위해 작은 단위 타입인 One과 ExcludeOne 타입을 각각 구현한 뒤, 두 타입을 활용해 하나의 타입 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<T> = One<T> + ExcludeOne<T>
type PickOne<T> = One<T> & ExcludeOne<T>;
작은 단위부터 단계별로 구현해 만든 타입 PickOne을 이용해 정확한 타입을 추론하도록 할 수 있다.
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
NonNullable 타입
type NonNullable<T> = T extends null | undefined ? never : T;
NonNullable 함수
function NonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
value)가 null 또는 undefined일 때 false를 반환하는 함수Promise.all을 사용할 때 NonNullable를 적용한 예시를 확인해보자.
const shopList = [
{ shopNo: 100, category: "chicken" },
{ shopNo: 101, category: "pizza" },
{ shopNo: 102, category: "noodle" },
];
class AdCampaignAPI {
static async operating(shopNo: number): Promise<AdCampaign[]> {
try {
return await fetch(`/ad/shopNumber=${shopNo}`);
} catch (error) {
return null;
}
}
}
const shopAdCampaignList = await Promise.all(
shopList.map((shop) => AdCampaignAPI.operating(shop.shopNo))
);
AdCampaignAPI.operating 함수는 error시 null을 반환하도록 되어있기 때문에 shopAdCampaignList의 타입은 Array<AdCampaign[] | null>로 추론된다. 이렇게 되면 순회할 때마다 고차 함수 내 콜백 함수에서 if문을 사용한 타입 가드를 반복하게 되는 문제가 생긴다. 아래와 같이 단순하게 필터링을 해도 null이 필터링 되지 않는다.
const shopAds = shopAdCampaignList.filter((shop) => !!shop);
// shopAds의 타입 : Array<AdCampaign[] | null>
다음과 같이 NonNullable를 이용해 null을 필터링해야만 Array<AdCampaign[]>로 추론하게 만들 수 있다.
// showAdCampaignList가 null이 될 수 있는 경우를 방어하기 위해 NonNullable 사용
const shopAds = shopAdCampaignList.filter(NonNullable);
// shopAds는 필터링을 통해 null이나 undefined가 아닌 값을 가진 배열이 됨
// shopAds의 타입 : Array<AdCampaign[]>
프로젝트를 진행하면서 상숫값을 관리할 때 흔히 객체를 사용한다. (ex. 스타일 theme 객체, 애니메이션 객체, 상수값을 담은 객체 등) 컴포넌트나 함수에서 이런 객체를 사용할 때 열린 타입(any)으로 설정할 수 있다.
const colors = {
red: "#F45452",
green: "#0C952A",
blue: "#1A7CFF",
};
const getColorHex = (key: string) => colors[key]; // 🚨 ERROR : Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ red: string; green: string; blue: string; }'.
// colors에 어떤 값이 추가될지 모르기 때문에 getColorHex의 반환값은 any
위는 함수 인자로 키를 받아 value를 반환하는 함수다. colors에 어떤 값이 추가될지 모르기 때문에 키 타입을 string으로 설정하면 getColorHex 함수의 반환값은 any가 된다.
여기에서 아래 방법을 통해 객체 타입을 더 정확하고 안전하게 설정할 수 있다.
const colors = {
red: "#F45452",
green: "#0C952A",
blue: "#1A7CFF",
} as const; // colors 객체를 불변 객체로 선언
const getColorHex = (key: keyof typeof colors) => colors[key];
// 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"'.
이 방법으로 객체 타입을 더 정확하고 안전하게 설정할 수 있다.
Atom 단위의 작은 컴포넌트(Button, Header, Input 등)는 색상 등의 스타일이 유연해야 하기 때문에 스타일을 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 객체의 스타일 키 값을
Button)에 props로 전달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>
);
};
// 컴포넌트 ButtonWrap이 props로 스타일 키 값(fontSize, backgroundColor, color)을 전달받음
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, backgoundColor, color 타입은 string.
Button 컴포넌트의 props로 넘겨줄 때 키 값이 자동 완성되지 않고 때문에 잘못된 키값을 넣어도 에러가 발생하지 않는 문제가 있다.
이를 theme 객체로 타입을 구체화해서 해결할 수 있다.
keyof 연산자로 객체의 키값을 타입으로 추출
interface ColorType {
red: string;
green: string;
blue: string;
}
type ColorKeyType = keyof ColorType; // "red" | "green" | "blue"
typeof 연산자로 값을 타입으로 다루기
const colors = {
red: "#F45452",
green: "#0C952A",
blue: "#1A7CFF",
};
type ColorsType = typeof colors; // { red: string; green: string; blue: string; }
실전 개선) 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>;
}
위처럼 theme 객체 타입을 구체화하고 string으로 타입을 설정했던 Button 컴포넌트를 개선하면 지정된 값만 받을 수 있게 된다. 자동완성으로 지정된 문자열(ex. "black", "default")을 받을 수도 있고, 다른 값을 넣었을 때 타입 오류가 발생하게 할 수 있다.
객체 선언 시 키가 어떤 값인지 명확하지 않으면 Record의 키를 string이나 number같은 원시 타입으로 명시하곤 하는데 이는 런타임 에러를 야기할 수 있어 주의가 필요하다.
Record를 명시적으로 사용하는 방안에 대해 알아보자.
type Category = string;
interface Food {
name: string;
// ...
}
const foodByCategory: Record<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
객체 foodByCatgory는 string 타입인 Category를 Record의 키로 사용하기 때문에 무한한 키 집합을 가진다. 키로 "한식", "일식"이 아닌 없는 키값(ex. "양식")을 사용하더라도 컴파일 오류 없이 undefined가 된다.
foodByCategory["양식"]; // Food[]로 추론
console.log(foodByCategory["양식"]); // ? undefined
foodByCategory["양식"].map((food) => console.log(food.name)); // 🚨 runTime ERROR : Cannot read properties of undefined (reading ‘map’)
위와 같이 undefined로 인한 런타임 에러를 방지하기 위해서 옵셔널 체이닝(?.)을 사용한다.
foodByCategory["양식"]?.map((food) => console.log(food.name)); // ✅
하지만 이 방법은 undefined일 수 있는 값을 인지하고 코드를 작성해야하기 때문에 예상치 못한 런타임 에러가 발생할 수 있다.
// before
type Category = string;
// after
type Category = "한식" | "일식";
키가 유한한 집합이라면 유닛 타입을 사용할 수 있다. 이렇게 하면 객체 foodByCategory에 없는 키값을 사용하면 오류를 표기한다.
foodByCategory["양식"]; // 🚨 ERROR : Property '양식' does not exist on type 'Record<Category, Food[]>'.
하지만 키가 무한해야 하는 상황에는 적합하지 않다.
키가 무한한 상황에서 Partial을 사용해 해당 값이 undefined일 수 있는 상태임을 표현할 수 있다.
객체 값이 undefined일 수 있는 경우에 Partial을 사용해서 PartialRecord 타입을 선언한다.
type PartialRecord<K extends string, T> = Partial<Record<K, T>>;
그리고 객체 foodByCategory를 선언할 때 Record 대신에 PartialRecord를 활용한다.
// before
const foodByCategory: Record<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
foodByCategory["양식"]; // Food[]로 추론
// after
const foodByCategory: PartialRecord<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
foodByCategory["양식"]; // Food[] 또는 undefined 타입으로 추론
객체 foodByCatgory는 무한한 키 집합을 가지면서 없는 키값을 사용하더라도 컴파일 오류를 반환한다.
// 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)); // ✅