타입스크립트의 조건부 타입은 자바스크립트의 삼항 연산자와 동일한 형태를 가진다.
이 절에서는 extends, infer, never 등을 활용해 원하는 타입을 만들어보며 어떤 상황에서 조건부 타입이 필요한지 알아본다.
extends와 제네릭을 활용한 조건부 타입
extends 키워드는 타입을 활장할 때와 타입을 조건부로 설정할 때 사용되며, 제네릭 타입에서는 한정자 역할로도 사용된다.
T extends U ? X : Y
조건부 타입에서 extends를 사용할 때는 자바스크립트 삼항 연산자와 함께 쓴다.
아래는 간단한 예시이다.
interface Bank {
financialCode: string;
companyName: string;
name: string;
fullName: string;
}
interface Card {
financialCode: string;
companyName: string;
name: string;
addCardType?: string;
}
type PayMethod<T> = T extends "card" ? Card : Bank;
type CardPayMethodType = PayMethod<"card">;
type BankPayMethodType = PayMethod<"bank">;
🚫 extends 키워드를 일반적으로 문자열 리터럴과 함께 사용하지는 않지만, 예시에서는 extends의 활용법을 설명하기 위해 문자열 리터럴에 사용되고 있다.
조건부 타입을 사용하지 않았을 때의 문제점
react-query를 활용한 예시로 계좌, 카드, 앱 카드 등 3가지 결제 수단 정보를 가져오는 API의 엔드포인트가 있고 각 API는 계좌, 카드, 앱 카드의 결제 수단 정보를 배열 형태로 반환한다.
서버 응답을 처리하는 공통 함수를 생성하고, 해당 함수에 타입을 전달하여 타입별로 처리 로직을 구현할 것이다.
함수를 구현하지 전에 사용되는 타입들을 서버에서 받아오는 타입, UI 관련 타입 그리고 결제 수단별 특성에 따라 구분하였다.
// 서버에서 받아오는 결제 수단 기본 타입으로 은행과 카드에 모두 들어가 있다.
interface PayMethodBaseFromRes {
financialCode: string;
name: string;
}
// 은행과 카드 각각에 맞는 결제 수단 타입이다. 결제 수단 기본 타입인 PayMethodBaseFromRes를 상속받아 구현한다.
interface Bank extends PayMethodBaseFromRes {
fullName: string;
}
interface Card extends PaymethodBaseFromRes {
appCardType?: string;
}
/*
최종적인 은행, 카드 결제 수단 타입이다. 프론트에서 추가되는 UI 데이터 타입과 제네릭으로 받아오는 Bank 또는 Card를 합성한다.
extends를 제네릭에서 한정자로 사용하여 Bank또는 Card를 포함하지 않는 타입은 제네릭으로 넘겨주지 못하게 방어한다.
BankPayMethodInfo = PayMethodInterface & Bank처럼 카드와 은행의 타입을 만들어줄 수 있지만 제네릭을 활용해서 중복된 코드를 제거한다.
*/
type PayMethodInfo<T extends Bank | Card> = T & PayMethodInterface;
//프론트에서 관리하는 결제 수단 관련 데이터로 UI를 구현하는 데 사용되는 타입이다.
type PayMethodInterface = {
companyName: string;
// ...
}
이제 react-query의 useQuery를 사용하여 구현한 커스텀 훅인 useGetRegisteredList 함수를 살펴보자
type PayMethodType = PayMethodInfo<Card> | PayMethodInfo<Bank>;
export const useGetRegisteredList = (
type: 'card' | 'appcard' | 'bank'
): useQueryResult<PayMethodType[]> => {
const url = `baeminpay/code/${type === 'appcard' ? 'card' : type}`;
const fetcher = fetcherFactory<PayMethodType[]>({
onSuccess: (res) => {
const usablePockerList = res?.filter(
(pocker: PocketInfo<Card> | PocketInfo<Bank>) => pocket?.useType === 'USE'
) ?? [];
return usablePockerList;
}
});
const result = useCommonQuery<PayMethodType[]>(url, undefined, fetcher);
return result;
}
하지만 useGetRegisteredList 함수가 반환하는 Data타입은 PayMethodType이기 떄문에 사용하는 쪽에서는 PocketInfo일 가능성도 있다.
useGetRegisteredList 함수는 타입을 구분해서 넣는 사용자의 의도와는 다르게 정확한 타입을 반환하지 못하는 함수가 됐다. 타입 설정이 유니온으로만 되어있기 때문에 타입스크립트는 해당 타입에 맞는 Data 타입을 추론할 수 없다. 반환되는 타입을 다르게 설정하고 싶다면 extends를 사용한 조건부 타입을 활용하면 된다.
extends 조건부 타입을 활용하여 개선하기
useGetRegisteredList 함수의 반환 Data는 인자 타입에 따라 정해져 있다. 타입으로 ‘card’ 또는 ‘appcard’를 받으면 카드 결제 수단 정보 타입인 PocketInfo를 반환하고, ‘bank’를 받는다면 PocketInfo를 반환한다.
조건부 타입을 사용해서 PayMethodInfo | PayMethodInfo 타입이었던 PayMethodType 타입을 개선해보자
type PayMethodType<T extends 'card'| 'appcard' | 'bank'> = T extends
| 'card'
| 'appcard'
? Card
: Bank;
새롭게 정의한 PayMethodType 타입에 제네릭 값을 넣어주기 위해서는 useGetRegisteredList 함수 인자의 타입을 넣어줘야한다. ‘card’, ‘appcard’, ‘bank’ 이외에 다른 값이 인자로 들어올 경우에는 타입 에러를 반환하도록 구현해보자.
export const useGetRegisteredList = <T extends 'card' | 'appcard' | 'bank'>(
type: T
): useQueryResult<PayMethodType<T>[]> => {
const url = `baeminpay/code/${type === 'appcard' ? 'card' : type}`;
const fetcher = fetcherFactory<PayMethodType<T>[]>({
onSuccess: (res) => {
const usablePockerList = res?.filter(
(pocker: PocketInfo<Card> | PocketInfo<Bank>) => pocket?.useType === 'USE'
) ?? [];
return usablePockerList;
}
});
const result = useCommonQuery<PayMethodType<T>[]>(url, undefined, fetcher);
return result;
}
조건부 타입을 활용하여 PayMethodType이 사용자가 인자에 넣는 타입 값에 맞는 타입만을 반환하도록 구현했다. 이로써 사용자는 useGetRegisteredList 함수를 사용할 때 불필요한 타입 가드를 하지 않아도 된다.
infer를 활용해서 타입 추론하기
inter
는 ‘추론하다’라는 의미를 지니고 있는데 타입스크립트에서도 다어 의미처럼 타입을 추론하는 역할을 한다.
type UnpackPromise<T> = T extends Promise<infer K>[] ? K : any;
UnpackPromise 타입은 제네릭으로 T를 받아 T가 Promise로 래핑된 경우라면 K를 반환하고, 그렇지 않은 경우에는 any를 반환한다.
const promises = [Promise.resolve('Mark'), Promise.resolve(38)];
type Expected = UnpackPromise<typeof promises>; // string | number
아래는 배민 라이더를 관리하는 라이더 어드민 서비스에서 사용하는 타입이다.
interface RouteBase {
name: string;
path: string;
component: ComponentType;
}
export interface RouteItem {
name: string;
path: string;
component?:ComponentType;
page?:RouteBase[];
}
export const routes: RouteItem[] = [
{
name: "기기 내역 관리",
path: "/device-history",
component: DeviceHistoryPage
},
{
name: "헬멧 인증 관리",
path: "/helmet-certification",
component: HelmetCertificationPage
},
]
RouterItem의 name은 pages가 있을 때는 단순히 이름의 역할만 하며 그렇지 않을 때는 사용자 권한과 비교한다.
export interface SubMenu {
name: string;
path: string;
}
export interface MainMenu {
name: string;
path?: string;
subMenus?: SubMenu[];
}
export type MenuItem = SubMenu | MainMenu;
export const menuList: MenuItem[] = [
{
name: "계정 관리",
subMenus: [
{
name: "기기 내역 관리",
path: "/device-history",
},
{
name: "헬멧 인증 관리",
path: "/helmet-certification",
}
]
}
]
MainMenu 와 SubMenu는 메뉴 리스트에서 사용하는 타입으로 권한 API를 통해 반환된 사용자 권한과 name을 비교하여 사용자가 접근할 수 있는 메뉴만 렌더링한다. MainMenu의 name은 subMenus를 가지고 있을 때 단순히 이름의 역할만 하며, 그렇지 않을 때는 권한으로 간주된다.
하지만 name은 string 타입으로 정의되어 있기 때문에 routes와 menuList에서 subMenu의 기기 내역 관리처럼 서로 다른 값이 입력되어도 컴파일타임에서 에러가 발생하지 않는다.
type PermissionNames = '기기 정보 관리' | '안전모 인증 관리' | '운행 여부 조회';
이를 개선하기 위해 PermissionNames 처럼 변도 타입을 선언하여 name을 관리하는 방법도 있지만, 권한 검사가 필요 없는 subMenu나 pages가 존재하는 name을 따로 처리해야한다.
이때 infer와 불변 객체(as const)를 활용해서 menuList 또는 routes의 값을 추출하여 타입으로 정의하는 식으로 개선할 수 있다.
export interface MainMenu {
// ...
subMenu?: ReadonlyArray<SubMenu>;
}
export const menuList = [
// ...
] as const;
interface RouteBase {
name: PermissionNames;
path: string;
component: ComponentType;
}
export type RouteItem =
| {
name: string;
path: string;
component?: ComponentType;
pages: RouteBase[];
}
| {
name: PermissionNames;
path: string;
component: ComponentType;
}
type UnpackMenuNames<T extends ReadonlyArray<MenuItem>> = T extends ReadonlyArray<infer U>
? U extedns MainMenu
? U['subMenus'] extends infer V
? V extends ReadonlyArray<SubMenu> ? UnpackMenuNames<V> : U['name']
: never
: U extends SubMenu ? U['name'] : never
:never;
그 다음 조건에 맞는 값을 추출한 UnpackMenuNames라는 타입을 추가했다.
타입스크립트에서는 유니온 타입을 사용하여 변수 타입을 특정 문자열로 지정할 수 있다.
type HeaderTag = "h1" | "h2" | "h3" | "h4" | "h5";
타입스크립트 4.1부터 이를 확장하는 방법인 템플릿 리터럴 타입을 지원하기 시작했다.
type HeadingNumber = 1 | 2| 3| 4| 5;
type HeaderTag = `h${HeadingNumber}`;
수평 또는 수직 방향을 표현하는 Direction타입을 다음과 같이 표현할 수 있다.
type Direction =
| "top"
| "topLeft"
| "topRight"
| "bottom"
| "bottomLeft"
| "bottomRight";
이 코드에 템플릿 리터럴 타입을 적용하면 다음과 같이 좀 더 명확하게 표시할 수 있다.
type Vertical = 'top' | 'bottom';
type Horizon = 'left' | 'right';
type Direction = Vertical | `${Vertical}${Capitalize<Horizon>}}`;
유틸리티 함수를 활용해 styled-components의 중복 타입 선언 피하기
Props 타입과 styled-components 타입의 중복 선언 및 문제점
export type Props = {
height?:string;
color?: keyof type of colors;
isFull?:boolean;
className?:string;
}
export const Hr: VFC<Props> = ({height, color, isFull, className}) => {
...
return <HrComponent height={height} color={color} isFull={isFull} className={className} />;
};
//styles.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 || 'gray']};
border: none;
${({isFull}) => isFull &&}
Hr 컴포넌트 Props의 속성은 컴포넌트인 HrComponent에 바로 연결되며 타입도 역시 같다.
styles.ts주석 아래 코드처럼 styledProps타입을 새로 정의하여도 Props와 똑같은 타입임에도 새로 작성해야하며 불가피하게 중복된 코드가 생긴다.
이런 문제를 Pick, Omit과 같은 유틸리티 타입으로 개선할 수 있다.
PickOne 유틸리티 함수
타입스크립트에서는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때 타입 검사가 제대로 진행되지 않는 이슈가 있다.
type Card = {
card: string
};
type Account = {
account: string
}
function withdraw(type: Card | Account){
...
}
withdraw({card: 'hyundai', account: 'hana'});
집합관점으로 볼 때 유니온은 합집합이 되기 때문에 에러가 발생하지 않는다.
이런 문제를 해결하기 위해 타입스크립트에서는 식별할 수 있는 유니온 기법을 자주 활용한다.
식별할 수 있는 유니온으로 객체 타입을 유니온으로 받기
아래 예시는 type이라는 속성을 추가하여 구분 짓는 방법이다.
type Card = {
type: "card";
card: string;
}
type Account = {
type: "account";
account: string;
}
function withfraw(type: Card | Account){
...
}
withdraw({type: 'card', card: 'hyundai'});
withdraw({type: 'account', account: 'hana'});
식별할 수 있는 유니온으로 문제를 해결할 수 있지만 일일이 type을 다 넣어줘야하는 불편함이 생긴다. 또한 이미 구현된 상태에서 식별할 수 있는 유니온을 적용하려면 해당 함수를 사용하는 부분을 모두 수정해야한다. 이러한 상황을 방지하기 위해 PickOne
이라는 유틸리티 타입을 구현하여 적용해보자.
PickOne 커스텀 유틸리티 타입 구현하기
구현하고자 하는 타입은 account 또는 card 속성 하나만 존재하는 객체를 받는 타입이다. 처음 작성한 타입으로 구현했을 때는 account와 card 속성을 모두 가진 객체도 허용되는 문제가 있었다.
하나의 타입이 들어왔을 때 다른 타입을 옵셔널한 undefined 값으로 지정하는 방법으로 작성한다면 원치 않는 속성에 값을 넣었을 때 타입 에러가 발생할 것이다.
{account: string; card?:undefined} | {account?:undefined; card: string}
이를 커스텀 유틸리티 타입으로 구현해보면 아래와 같다.
type PickOne<T> = {
[P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];
PickOne 살펴보기
앞의 유틸리티 타입을 하나씩 자세하게 뜯어보자. PickOne 타입을 2가지 타입으로 분리해서 생각할 수 있다.
One
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
ExcludeOne
type ExcludeOne<T> = { [P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>> }[keyof T];
PickOne
type PiclOne<T> = One<T> & ExcludeOne<T>
One & ExcludeOne는 [P in keyof T]를 공통으로 갖기 떄문에 아래 같이 교차된다.
[P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>
이 타입을 해석하면 전달된 T 타입의 1개의 키는 값을 가지고 있으며, 나머지 키는 옵셔널한 undefined 값을 가진 객체를 의미한다.
NonNullable 타입 검사 함수를 사용하여 간쳔하게 타입 가드하기
일반적으로 if문을 사용해서 null 처리 타입 가드를 적용하지만, is 키워드와 NonNullable 타입으로 타입 검사를 위한 유틸 함수를 만들어서 사용할 수도 있다.
NonNullable 타입이란
타입스크립트에서 제공하는 유틸리티 타입으로 제네릭으로 받는 T가 null 또는 undefined일 때 never 또는 T를 반환하는 타입이다. NonNullable을 사용하면 null이나 indefined가 아닌 경우를 제외할 수 있다.
type NonNullable<T> = T extends null | undefined ? never : T;
null, undefined를 검사해주는 NonNullable 함수
NonNullable 함수는 매개변수인 value가 null 또는 undefined라면 false를 반환한다. is 키워드가 쓰였기 때문에 NonNullable 함수를 사용하는 쪽에서 true가 반환된다면 넘겨준 인자는 null이나 undefined가 아닌 타입으로 타입 가드가 된다.
function NonNullable<T>(value: T) = value is NonNullable<T> {
return value !== null && value !== undefined;
}
Promise.all을 사용할 때 NonNullable 적용하기
아래는 각 상품 광고를 노출하는 api함수 레이어다. 여러 상품을 조회할 때 하나의 API에서 에러가 발생한다고 해서 전체 광고가 보이지 않으면 안 된다. 따라서 try-catch문을 사용하여 에러가 발생할 때는 null을 반환하고 있다.
class AdCampaignAPI {
static async operating(shopNo: number): Promise<AdCampaign[]>{
try{
return await fetch(`/ad/shopNumber=${shopNo}`);
}catch(error){
return null;
}
}
}
아래는 AdCampaignAPI를 사용해서 여러 상품의 광고를 받아오는 로직이다.
const shopList = [
{shopNo: 100, category: 'chicken'},
{shopNo: 101, category: 'pizza'},
{shopNo: 102, category 'noodle'}
];
const shopAdCampaignList = await Pormise.all(
shopList.map((shop)=> AdCampaignAPI.operating(shop.shopNo))
);
이때 AdCampaignAPI.operating함수에서 null을 반환할 수 있기 때문에 shopAdCampaignList 타입은 Array<AdCampaignAPI[] | null>로 추론된다.
shopAdCampaignList 변수를 NonNullable 함수로 필터링하지 않으면 shopAdCampaignList를 순회할 때마다 고차 함수 내 콜백 함수에서 if문을 사용한 타입 가드를 반복하게 된다.
다음과 같은 방법으로 작성하면 Array<AdCampaignAPI[]>로 추론할 수 있게 된다.
const shopList = [
{shopNo: 100, category: 'chicken'},
{shopNo: 101, category: 'pizza'},
{shopNo: 102, category 'noodle'}
];
const shopAdCampaignList = await Pormise.all(
shopList.map((shop)=> AdCampaignAPI.operating(shop.shopNo))
);
const shopAds = shopAdCampaignList.filter(NonNullable);
컴포넌트나 함수에서 이런 객체를 사용할 때 열린 타입으로 설정할 수 있다. 함수 인자로 키를 받아서 value를 반환하는 함수를 보자. 키 타입을 해당 객체에 존재하는 키값으로 설정하는 것이 아니라 string으로 설정하면 getColorHex함수의 반환 값은 any가 된다.
const colors = {
red: '#F45452',
green: '#0c9252a'
blue: '#1A7cff'
}
const getColorHex = (key: string) => colors[key];
여기서 as const 키워드로 객체를 불변 객체로 선언하고, keyof 연산자를 사용하면 getColorHex 함수 인자로 실제 colors 객체에 존재하는 키값만 받도록 설정할 수 있다.
interface ColorType {
red: string;
green: string;
blue: string;
}
type ColorKeyType = keyof ColorType; //'red' | 'green' | 'blue'
타입스크립트 typeof 연산자로 값을 타입으로 다루기 keyof 연산자는 객체 타입을 받는다. 따라서 객체의 키값을 타입으로 다루려면 값 객체를 타입으로 변환해야 한다. 이때 typeof 연산자를 활용할 수 있다. 타입스크립트의 typeof 연산자는 단독으로 사용되기보다 주로 ReturnType같이 유틸리티 타입이나 keyof 연산자같이 타입을 받는 연산자와 함께 쓰인다.const colors = {
red: '#F45452',
green: '#0C952A',
blue: '#1A7CFF'
}
type ColorsType = typeof colors;
/**
{
red: string;
green: string;
blue: string;
}
*/
객체의 타입을 활용해서 컴포넌트 구현하기import {FC} from 'react';
import styled from 'styled-components';
const colors = {
black:'#000000',
gray: '#222222',
white: '#ffffff',
mint: '#2ac1bc'
}
const theme = {
colors: {
default: colors.gray,
...colors
},
backgdroundColor: {
default: colors.white,
gray: colors.gray,
mint: colors.mint,
black: colors.black
},
fontSize: {
default: '16px',
small: '14px',
large: '18px'
}
}
type ColorType = typeof keyof theme.colors;
type BackgroundColorType = typeof keyof theme.backgroundColor;
type FontSizeType = typeof keyof 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, onClick}) => {
return (
<ButtonWrap
fontSize={fontSize}
backgroundColor={backgroundColor}
color={color}
>
{children}
</ButtonWrap>
)
}
const ButtonWrap = styled.button<Omit<Props, 'onClick'>>`
color: ${({color}) => theme.color[color ?? 'default']};
backgorund-color: ${({backgroundColor}) => theme.bgColor[backgroundColor ?? 'default']};
font-size: ${({fontSize}) => theme.fontSize[fontSize ?? 'defulat']};
`
type Category = string;
interface Food {
name: string;
// ...
}
const foodByCategory: Record<Category, Food[]> = {
한식: [{name:'제육덮밥'}, {name: '뚝배기 불고기'}],
일식: [{name: '초밥'}, {name: '텐동'}]
}
foodByCategory['양식'];
foodByCategory['양식'].map((food) => console.log(food.name)); // 오류가 발생하지 않는다.
foodByCategory 객체에 없는 키값을 사용하더라고 타입스크립트는 오류를 표시하지 않는다. 그러나 foodByCategory['양식']은 런타임에서 undefined가 되어 오류를 반환한다. 이때 자바스크립트의 옵셔널 체이닝 등을 사용해 런타임 에러를 방지할 수 있다. 하지만 어떤 값이 undefined인지 매번 판단해야 한다는 번거로움이 생긴다. 또한 실수로 옵셔널 체이닝을 놓치는 경우에 런타임 에러가 발생할 수 있다. 하지만 타입스크립트의 기능을 활용하여 개발 중에 유효하지 않은 키가 사용되었는지 또는 undefined일 수 있는 값이 있는지 등을 사전에 파악할 수 있다.type Category = '한식' | '일식';
interface Food {
name: string;
//...
}
const foodByCategory: Record<Category, Food[]> = {
한식: [{name:'제육덮밥'}, {name: '뚝배기 불고기'}],
일식: [{name: '초밥'}, {name: '텐동'}]
}
그러나 키가 무한해야 하는 상황에는 적합하지 않다.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['양식'];
foodByCategory['양식'].map((food) => console.log(food.name)); // Object is possibly 'undefined'
타입스크립트는 개발자에게 이 값은 undefined일 수 있으니 해당 값에 대한 처리가 필요하다고 표시해준다.