타입스크립트에는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때, 타입 검사가 제대로 진행되지 않는 이슈가 있음
이런 문제를 해결하기 위해 PickOne이라는 이름의 유틸리티 함수를 이용
type Card = {
card: string;
};
type Account = {
account: string;
};
function withdraw(type: Card | Account) {
...
}
widthdraw({ card: "hyundai", account: "hana" });
위의 코드를 보면 Card, Account 중 하나의 객체만 받고 싶어서 Card | Account 로 타입을 작성하면 의도한 대로 타입 검사가 이루어지지 않음
withdraw 함수의 인자로 { card: 'hyundai' } 또는 { account: 'hana' } 중 하나만 받고 싶지만 실제로는 card, account 속성을 모두 받아도 타입 에러가 발생하지 않음
왜 타입 에러가 발생하지 않을까?
집합 관점으로 볼 때, 유니온은 합집합이 되기 때문!
=> 따라서, card, account 속성이 하나씩만 할당된 상태도 허용하지만 card, account 속성이 모두 포함되어도 합집합의 범주에 들어가기 때문에 타입에러 발생하지 않음
식별할 수 있는 유니온은 각 타입에 type이라는 공통된 속성을 추가해 구분짓는 방법
type Card = {
type: "card"; // 식별할 수 있는 유니온
card: string;
};
type Account = {
type: "account"; // 식별할 수 있는 유니온
account: string;
};
function withdraw(type: Card | Account) {
...
}
widthdraw({ type: "card", card: "hyundai" });
widthdraw({ type: "account", card: "hana" });
위의 예시를 보면 Card, Account 타입을 구분할 수 있도록 type 이라는 속성이 추가된 것을 볼 수 있음
식별할 수 있는 유니온을 활용해 공통된 속성인 type을 기준으로 객체를 구분할 수 있어서 withdraw 함수를 사용하는 곳에서 정확한 타입을 추론할 수 있게 됨
but, 식별할 수 있는 유니온을 사용하면 일일이 type을 다 넣어줘야하는 문제점 발생 & 실수로 수정하지 않은 부분 생기면 또 다른 문제 발생
=> 이러한 상황을 방지하기 위해 PickOne 이라는 유티릴티 타입을 구현하여 적용함
타입스크립트에서 제공하는 유틸리티 타입을 활용하기 위해 커스텀 유틸리티 타입을 만들어야 함
구현하고자 하는 타입은 account 또는 card 속성 하나만 존재하는 객체를 받는 타입
처음에 작성한 것처럼 { account: string } | { card: string}으로 타입을 구현했을 때는 account와 card 속성을 모두 가진 객체도 허용되는 문제가 있었음
account일 때는 card를 받지 못하고, card일 때는 account를 받지 못하게 하려면 하나의 속성이 들어왔을 때 다른 타입을 옵셔널한 undefined 값으로 지정하는 방법을 생각해 볼 수 있음
{ account: string; card?: undefined } | { account?: undefined; card: string}
옵셔널 + undefined로 타입을 지정하면 사용자가 의도적으로 undefined 값을 넣지 않는 이상, 원치 않는 속성에 값을 넣었을 때 타입 에러가 발생함
이 타입을 정확히 이해하기 위해 account, card, payMoney 속성 중 하나만을 필수로 받는 PayMethod를 구현하면
type PayMethod =
| { account: string; card?: undefined; payMoney?: undefined }
| { account: undefined; card?: string; payMoney?: undefined }
| { account: undefined; card?: undefined; payMoney?: string }
선택하려는 하나의 속성을 제외한 나머지 값을 옵셔널 타입 + undefined로 설정하면 원하고자 하는 속성만 받도록 구현할 수 있다. 이를 커스텀 유틸리티 타입으로 구현해보면
type PickOne<T> = {
[P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>;
}[keyof T];
이때 T에는 객체가 들어 온다고 가정하고
"One<T>"
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
1) [P in keyof T]에서 T는 객체로 가정하므로 P는 T 객체의 키값을 말함
2) Record<P, T[P]>는 P타입을 키로 가지고, value는 P를 키로 둔 T 객체의 값의 레코드 타입
3) 따라서, { [P in keyof T]: Record<P, T[P]> }에서는 key는 T 객체의 키 모음이고, value는 해당 키의 원본 객체 T를 말함
4) 3번의 타입에서 다시 [keyof T]의 키값으로 접근하기 때문에 최종 결과는 전달받은 T와 같음
type Card = { card: string };
const one: One<Card> = {card: "hyundai"};
역시 T에는 객체가 들어옴
"ExcludeOne<T>"
type ExcludeOne<T> = { [P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];
1) [P in keyof T]에서 T는 객체로 가정하기 때문에 P는 T 객체의 키값을 말함
2) Exclude<keyof T, P>는 T 객체가 가진 키값에서 P 타입과 일치하는 키값을 제외함.
이 타입을 A라고 하면 (치환)
3) Record<A, undefined>는 키로 A 타입을, 값으로 undefined 타입을 갖는 레코드 타입. 즉, 전달받은 객체 타입을 모두 { [key] : undefined } 형태로 만듦.
이 타입을 B라고 하면 (또다시 치환)
4) Partial<B>는 B타입을 옵셔널로 만듦. 따라서 { [key]?: undefined }와 같음
5) 최종적으로 [P in keyof T]로 매핑된 타입에서 동일한 객체의 키값인 [keyof T]로 접근하기 때문에 4번 타입이 반환됨
결론으로, 타입은 속성 하나와 나머지는 옵션 + undefined인 타입이기 때문에 앞의 속성을 활용해 PickOne 타입을 표현할 수 있음
"PickOne<T>"
type PickOne<T> = One<T> & ExcludeOne<T>
(1) One & ExcludeOne는 [P in keyof T]를 공통으로 갖기 떄문에 아래처럼 교차됨
[P in keyof T]: Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>
(2) 이 타입을 해석하면 전달된 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을 활용해 앞의 코드를 수정해보면, withdraw({ card: "hyundai", account: "hana" })를 활용할 때 타입 에러가 발생하는 것 확인 할 수 있음
type Card = {
card: string
};
type Account = {
account: string
};
type CardOrAccount = PickOne<Card & Account>;
function withdraw (type: CardOrAccount) {
...
}
withdraw( {card: "hyundai", account: "hana" }); // 에러 발생함
유틸리티 타입만으로 원하는 타입을 추출하기 어려울 때, 커스텀 유틸리티 타입을 구현함 (하지만 쉬운일은 아님) -> 커스텀 유틸리티 타입을 구현할 때는 정확히 어떤 타입을 구현해야하는지 파악하고, 필요한 타입을 작은 단위로 쪼개어 생각해 단계적으로 구현하는게 좋음
type NonNullable<T> = T extends null | undefined ? never : T;
function NonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
Promise.all을 사용할 때 NonNullable를 적용한 예시를 보면
1. 각 상품 광고를 노출하는 API 함수 레이어
class AdCampaignAPI {
static async operating(shopNo: number): Promise<AdCampaign[]> {
try {
return await fetch(`/ad/shopNumber=${shopNo}`);
} catch {
return null;
}
}
}
AdCampaignAPI를 사용해서 여러 상품의 광고를 받아오는 로직. Promise.all을 사용해 각 shop의 광고를 받음
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))
);
이때 AdCampaignAPI.operating 함수에서 null을 반환할 수 있기 때문에 shopAdCampaignList 타입은 Array<AdCampaign[] | null>로 추론됨
shopAdCampaignList 변수를 NonNullable 함수로 필터링하지 않으면 shopAdCampaignList를 순회할 때(map이나 foreach 순회 메서드를 사용할때)마다 고차 함수 내 콜백 함수에서 if문을 사용한 타입 가드를 반복하게 됨
NonNullable 함수를 사용하지 않고 단순하게 필터링한다면 [shopAdCampaignList.filter((shop) => !!shop)]가 원하는 Array<AdCampaign[]> 타입으로 추론되는 것이 아니라 null이 될 수 있는 상태인 Array<AdCampaign[] | 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);
NonNullable을 사용해서 shopAdCampaignList를 필터링하면 shopAds는 원하는 타입인 Array<AdCampaign[]>로 추론할 수 있게 됨
플젝을 진행하면 상숫값을 관리할 때 객체를 사용함
예를 들어, 프로젝트의 전체적인 스타일을 관리하는 theme 객체, 자주 사용하는 애니메이션을 모아둔 객체, 상숫값을 담은 객체 등 다양한 곳에 활용됨
컴포넌트나 함수에서 이런 객체를 사용할 때 열린 타입으로 설정할 수 있는데, 함수 인자로 키를 받아서 value를 반환하는 함수를 보면
const colors = {
red: "#f45452",
green: "#0c952a",
blue: "#1a7cff",
};
const getColorHex = (key: string) => colors[key];
키 타입을 해당 객체에 존재하는 키값으로 설정하는 것이 아니라 string으로 설정하면 getColorHex 함수의 반환 값은 any가 된다. colors에 어떤 값이 추가될지 모르기 때문.
여기서 as const 키워드로 객체를 불변 객체로 선언하고, keyof 연산자를 사용해 getColorHex 함수 인자로 실제 colors 객체에 존재하는 키값만 받도록 설정할 수 있음
keyof, as const로 객체 타입을 구체적으로 설정하면 타입에 맞지 않는 값을 전달할 경우 타입 에러가 반환 -> 컴파일 단계에서 발생할 수 있는 실수 방지 & 자동 완성 기능을 통해 어떤 값이 있는 쉽게 파악 가능
=> 이런 방법으로 객체 타입을 더 정확하고 안전하게 설정할 수 있음!
Atom 단위의 작은 컴포넌트(Button, Header, Input 등)는 폰트 크기, 폰트 색상, 배경 색상 등 다양한 환경에서 유연하게 사용될 수 있도록 구현되어야 하는데 이러한 설정값은 props로 넘겨주도록 설계함
props로 직접 색상 값을 넘겨줄 수도 있지만 그렇게 하면 사용자가 모든 색상 값을 인지해야하고, 변경 사항이 생길 때 직접 값을 넣은 모든 곳을 찾아 수정해야함
이런 문제를 해결하기 위해 대부분의 프로젝트에서는 해당 프로젝트의 스타일 값을 관리해주는 theme 객체를 두고 관리함
-> Atom 컴포넌트에서 theme 객체의 색상, 폰트 사이즈 등의 키 값을 props로 받은 뒤, theme 객체에서 값을 받아오도록 설계
컴포넌트에서 props의 colors, fontSize 등 값을 정의할 때는 아래처럼 string으로 설정 할 수도 있음
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 = style.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, typeof 연산자가 타입스크립트에서 어떻게 사용되는지 알아야함
keyof 연산자는 객체 타입을 받아 해당 객체의 키값을 string 또는 number의 리터럴 유니온 타입을 반환함
객체 타입으로 인덱스 시그니처가 사용되었다면 keyof는 인덱스 시그니처의 키 타입을 반환함
interface ColorType {
red: string;
green: string;
blue: string;
}
type ColorKeyType = keyof ColorType; // 'red' | 'green' | 'blue'
ColorType 객체 타입의 keyof ColorType을 사용하면 객체의 키값인 red, green, blue가 유니온으로 나오게 된다.
keyof 연산자는 객체 타입을 받음 -> 따라서 객체의 키값을 타입으로 다루려면 값 객체를 타입으로 변환해야 하는데, 이때 타입스크립트의 typeof 연산자를 활용할 수 있음
자바스크립트에서는 typeof가 타입을 추출하기 위한 연산자로 사용된다면, 타입스크립트에서는 typeof가 변수 혹은 속성의 타입을 추론하는 역할을함
타입스크립트의 typeof 연산자는 단독으로 사용되기보다 주로 ReturnType과 같이 유틸리티 타입이나 keyof 연산자와 같이 타입을 받는 연산자와 함께 쓰임
const colors = {
red: "#f45452",
green: "#0c952a",
blue: "#1a7cff",
};
type ColorsType = typeof colors;
/**
{
red: string;
green: string;
blue: string;
}
*/
keyof, typeof 연산자를 사용해서 theme 객체 타입을 구체화하고, string으로 타입을 설정했던 Button 컴포넌트를 개선해보면
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
},
backgroundColor: {
default: colors.white,
gray: colors.gray,
mint: colors.mint,
black: colors.blakck,
},
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 }) => {
return (
<ButtonWrap fontSize={fontSize} backgroundColor={backgroundColor} color={color}>
{children}
</ButtonWrap>
)
}
const ButtonWrap = styled.button<Omit<Props, "onClick">>`
color: ${({ color }) => theme.colors[color ?? "default"]};
background-color: ${({ backgroundColor }) => theme.backgroundColor[backgroundColor ?? "default"]};
font-size: ${({ fontSize }) => theme.fontSize[fontSize ?? "default"]};
`;
예를 들어, Button 컴포넌트를 사용하는 곳에서 backgroundColor의 값만 받을 수 있게 되었고, 다른 값을 넣었을 때는 타입 오류가 발생한다.
이처럼 theme 뿐만 아니라 여러 상숫값을 인자나 props로 받은 다음에 객체의 키 값을 추출한 타입을 활용하면 객체에 접근할때 타입스크립트의 도움을 받아 실수를 방지할 수 있다!
객체 선언시 키가 어떤 값인지 명확하지 않다면 Record의 키를 string이나 number 같은 원시 타입으로 명시하곤 함
-> 이때 타입스크립트는 키가 유효하지 않더라도 타입상으로는 문제 없기 때문에 오류를 표시하지 않음
but!! 이것은 예상치 못한 런타임 에러를 야기할 수 있음
따라서, Record를 명시적으로 사용하는 방법을 사용하면 됨
type Category = string;
interface Food {
name: string;
// ...
}
const foodByCategory: Record<Category, Food[]> = {
한식: [{ name: "제육덮밥"}, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
음식 분류(한식, 일식)를 키로 사용하는 음식 배열이 담긴 객체를 만들었음
여기에서 Category의 타입은 string이고, Category를 Record의 키로 사용하는 foodByCategory 객체는 무한한 키 집합을 가지게 됨
이때 foodByCategory 객체에는 없는 키값을 사용하더라도 타입스크립트는 오류를 표시하지 않음!
foodByCategory["양식"]; // Food[]로 추론
foodByCategory["양식"].map((food) => console.log(food.name)); // 오류 발생하지 않음
그러나 foodByCategory["양식"]은 런타임에서 undefined가 되어 오류를 반환함
이때 자바스크립트의 옵셔널 체이닝 등을 사용해 런타임 에러를 방지할 수 있음
*옵셔널 체이닝(optional chaining) : 객체의 속성을 찾을 때 중간에 null 또는 undefined가 있어도 오류 없이 안전하게 접근하는 방법-> ?.문법으로 표현되며 옵셔널 체이닝을 사용할 때 중간에 null 또는 undefined인 속성이 있는지 검사한다. 속성이 존재하면 해당 값을 반환하고, 존재하지 않으면 undefined를 반환한다.
foodByCategory["양식"]?.map((food) => console.log(food.name));
그러나 어떤 값이 undefined인지 매번 판단해야 하는 번거로움이 생김 & 실수로 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["양식"];
Category로 한식 또는 일식만 올 수 있기 때문에 양식을 키로 사용하면 에러 발생
유닛 타입을 활용하면 개발 중에 유효하지 않은 키가 사용되었는지를 확인할 수 있음
그러나 키가 무한해야 하는 상황에는 적합하지 않음
키가 무한한 상황에서는 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
타입스크립트는 foodByCategory[key]를 Fodd[] 또는 undefined로 추론하고, 개발자에게 이 값은 undefined일 수 있으니 해당 값에 대한 처리가 필요하다고 표시해줌 -> 개발자는 안내를 보고 옵셔널 체이닝을 사용하거나 조건문을 사용하는 등 사전에 조치할 수 있어서 예상치 못한 런타임 오류를 줄일 수 있음