
기존 타입을 사용해서 새로운 타입을 정의하는 것을 말한다.
[Typescript] 고급타입 한번에 보기 ← 여기에 기초적인 용어들을 정리했다.
| 1인분 | 족발, 보쌈 | 찜, 탕, 찌개 | 돈까스, 회, 일식 | 피자 |
|---|
/**
* 메뉴에 대한 타입
* 메뉴 이름과 메뉴 이미지에 대한 정보를 담고 있다
*/
interface Menu {
name: string;
image: string;
}
메뉴 인터페이스를 표현했다.
function MainMenu() {
// Menu 타입을 원소로 갖는 배열
const menuList: Menu[] = [{name: “1인분”, image: “1인분.png”}, ...]
return (
<ul>
{menuList.map((menu) => (
<li>
<img src= {menu.image} />
<span>{menu.name}</span>
</li>
))}
</ul>
)
}
그리고 개발자는 메뉴 인터페이스를 기반으로 사용자에게 위 화면을 보여줬다.
⚠️ 요구사항 추가
1. 특정 메뉴를 길게 누르면 gif 파일을 재생시켜 주세요
2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출시켜 주세요
/**
* 각 배열은 서버에서 받아온 응답 값이라고 가정
*/
const menuList = [
{ name: "찜", image: "찜.png" },
{ name: "찌개", image: "찌개.png" },
{ name: "회", image: "회.png" },
];
const specialMenuList = [
{ name: "돈까스", image: "돈까스.png", gif: "돈까스.gif" },
{ name: "피자", image: "피자.png", gif: "피자.gif" },
];
const packageMenuList = [
{ name: "1인분", image: "1인분.png", text: "1인 가구 맞춤형" },
{ name: "족발", image: "족발.png", text: "오늘은 족발로 결정" },
];
위처럼 3가지 종류의 메뉴 목록이 있을 때, 각 방법을 적용해보자.
두 가지 방법으로 생각해볼 수 있다.
/**
* 방법1 타입 내에서 속성 추가
* 기존 Menu 인터페이스에 추가된 정보를 전부 추가
*/
interface Menu {
name: string;
image: string;
gif?: string; // 요구 사항 1. 특정 메뉴를 길게 누르면 gif 파일이 재생되어야 한다
text?: string; // 요구 사항 2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다
}
menuList: Menu[] // OK
specialMenuList: Menu[] // OK
packageMenuList: Menu[] // OK
각 메뉴 목록은 Menu[]로 표현할 수 있다.
specialMenuList.map((menu) => menu.text);
// TypeError: Cannot read properties of undefined
근데 배열의 원소가 각 속성에 접근한다고 했을 때, 문제가 발생했다.
specialMenuList는 Menu타입의 원소를 갖기 때문에 text 속성에 접근할 수 있지만text를 갖고 있는게 아니다./**
* 방법2 타입 확장 활용
* 기존 Menu 인터페이스는 유지한 채, 각 요구 사항에 따른 별도 타입을 만들어 확장시키는 구조
*/
interface Menu {
name: string;
image: string;
}
/**
* gif를 활용한 메뉴 타입
* Menu 인터페이스를 확장해서 반드시 gif 값을 갖도록 만든 타입
*/
interface SpecialMenu extends Menu {
gif: string; // 요구 사항 1. 특정 메뉴를 길게 누르면 gif 파일이 재생되어야 한다
}
/**
* 별도의 텍스트를 활용한 메뉴 타입
* Menu 인터페이스를 확장해서 반드시 text 값을 갖도록 만든 타입
*/
interface PackageMenu extends Menu {
text: string; // 요구 사항 2. 특정 메뉴는 이미지 대신 별도의 텍스트만 노출되어야 한다
}
menuList: Menu[] // OK
specialMenuList: Menu[] // NOT OK
specialMenuList: SpecialMenu[] // OK
packageMenuList: Menu[] // NOT OK
packageMenuList: PackageMenu[] // OK
각 배열의 타입을 확장할 타입에 맞게 명확히 규정할 수 있다.
specialMenuList.map((menu) => menu.text);
// Property ‘text’ does not exist on type ‘SpecialMenu’
이를 바탕으로 specialMenuList 배열의 원소 내 속성에 접근한다고 하더라도, 프로그램을 실행하지 않아도 타입이 잘못되었음을 미리 알 수 있다.
변수 또는 표현식의 타입 범위를 더 작은 범위로 좁혀나가는 과정
조건문과 타입 가드를 활용해 타입 범위를 좁히자.
타입 가드: 런타임에 조건문을 사용하여 타입을 검사하고, 타입 범위를 좁혀주는 기능
스코프(scope): 변수와 함수 등의 식별자가 유효한 범위. 변수와 함수를 선언하거나 사용할 수 있는 영역을 말한다. JS 실행컨텍스트, 스코프
런타임에 유효한 타입 가드를 위해서, 자바스크립트 연산자를 사용해야 하며 typeof, instanceof, in 연산자를 활용할 것이다. 그리고 사용자 정의 타입가드로 마무리한다.
typeof 연산자를 활용하면 원시 타입에 대해 추론할 수 있다.
typeof A === B를 조건으로 분기처리하면, 해당 분기 내에서 A의 타입이 B로 추론된다.null과 배열 타입 등이 object 타입으로 판별되는 등 복잡한 타입을 검증하기엔 한계가 있다.typeof 연산자는 주로 원시 타입을 좁히는 용도로 사용할 것을 권장한다.
typeof를 사용하여 검사할 수 있는 타입 목록
- string
- number
- boolean
- undefined
- object
- function
- bigint
- symbol
const replaceHyphen: (date: string | Date) => string | Date = (date) => {
if (typeof date === "string") {
// 이 분기에서는 date의 타입이 string으로 추론된다
return date.replace(/-/g, "/");
}
return date;
};
interface Range {
start: Date;
end: Date;
}
interface DatePickerProps {
selectedDates?: Date | Range;
}
const DatePicker = ({ selectedDates }: DatePickerProps) => {
const [selected, setSelected] = useState(convertToRange(selectedDates));
// ...
};
export function convertToRange(selected?: Date | Range): Range | undefined {
return selected instanceof Date
? { start: selected, end: selected }
: selected;
}
selected 매개변수가 Date인지 검사한 후에 Range 타입의 객체를 반환할 수 있도록 분기 처리하고 있다.
A instanceof B 형태로 사용되며A에는 타입을 검사하는 대상 변수, B에는 특정 객체의 생성자가 들어간다.true와 false를 반환한다.객체의 속성이 있는지 확인한 후 true, false를 반환한다.
in 연산자는 B 객체 내부에 A 속성이 있는지 검사하는 것이기에, A 속성이 undefined를 할당한다고 해서 false를 반환하는게 아니다.interface BasicNoticeDialogProps {
noticeTitle: string;
noticeBody: string;
}
interface NoticeDialogWithCookieProps extends BasicNoticeDialogProps {
cookieKey: string;
noForADay?: boolean;
neverAgain?: boolean;
}
export type NoticeDialogProps =
| BasicNoticeDialogProps
| NoticeDialogWithCookieProps;
const NoticeDialog: React.FC<NoticeDialogProps> = (props) => {
if ("cookieKey" in props) return <NoticeDialogWithCookie {...props} />;
return <NoticeDialogBase {...props} />;
};
NoticeDialog 컴포넌트는 2가지 객체의 유니온 타입인 NoticeDialogProps를 props 받는다.
NoticeDialog 컴포넌트가 props로 받는 객체 타입에 따라 렌더링하는 컴포넌트가 달라지도록 하려고 할 때in 연산자로 특정 속성을 가지는지 분기처리하면 된다.
JS에서의 in 연산자: 런타임의 값만 검사한다.
TS에서의 in 연산자: 객체 타입에 속성이 존재하는지를 검사한다.
반환 타입이 타입 명제(type predicates)인 함수를 정의하여 사용할 수 있다.
타입 명제: 함수의 반환 타입에 대한 타입 가드를 수행하기 위해 사용되는 함수
const isDestinationCode = (x: string): x is DestinationCode =>
destinationCodeList.includes(x);
isDestinationCode : string 타입의 매개변수가 destinationCodeList배열의 원소 중 하나인지를 검사하여 boolean을 반환하는 함수
x is DestinationCode로 타이핑해 단순히 boolean을 반환하는 것을 넘는 기능을 한다.true를 반환했을 때, 그 스코프 내의 x는 DestinationCode타입임을 확정시킨다.const isDestinationCode = (x: string): boolean =>
destinationCodeList.includes(x);
const getAvailableDestinationNameList = async (): Promise<DestinationName[]> => {
const data = await AxiosRequest<string[]>(“get”, “.../destinations”);
const destinationNames: DestinationName[] = [];
data?.forEach((str) => {
if (isDestinationCode(str)) {
destinationNames.push(DestinationNameSet[str]);
/*
isDestinationCode의 반환 값에 is를 사용하지 않고 boolean이라고 한다면 다음 에러가
발생한다
- Element implicitly has an ‘any’ type because expression of type ‘string’
can’t be used to index type ‘Record<”MESSAGE_PLATFORM” | “COUPON_PLATFORM” | “BRAZE”,
“통합메시지플랫폼” | “쿠폰대장간” | “braze”>’
*/
}
});
return destinationNames;
};
개발자는 if문 내부에서 str타입이 DestinationCode인 것을 알고 있다.
isDestinationCode 내부의 includes 함수를 해석하지 못한다.종종 태그된 유니온(Tagged Union)으로도 불리는 식별할 수 있는 유니온(Discriminated Unions)은 타입 좁히기에 널리 사용되는 방식이다.
언제 사용하는지, 장점은 무엇인지 알아보자.
type TextError = {
errorCode: string;
errorMessage: string;
};
type ToastError = {
errorCode: string;
errorMessage: string;
toastShowDuration: number; // 토스트를 띄워줄 시간
};
type AlertError = {
errorCode: string;
errorMessage: string;
onConfirm: () => void; // 얼럿 창의 확인 버튼을 누른 뒤 액션
};
이를 기반으로 아래 배열을 정의했다.
type ErrorFeedbackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
{ errorCode: "100", errorMessage: "텍스트 에러" },
{ errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000 },
{ errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {} },
];
이로써 다양한 에러 객체를 관리할 수 있게 되었다.
const errorArr: ErrorFeedbackType[] = [
// ...
{
errorCode: "999",
errorMessage: "잘못된 에러",
toastShowDuration: 3000,
onConfirm: () => {},
}, // expected error
];
ToastError의 toastShowDuration 필드와 AlertError의 onConfirm 필드를 모두 가지는 객체를 만들었다.
따라서 에러 타입을 구분할 방법이 필요한데, 이때 이 ‘식별할 수 있는 유니온’을 사용하면 된다.
식별할 수 있는 유니온: 타입 간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자를 달아주어 포함관계를 제거하는 것
type TextError = {
errorType: "TEXT";
errorCode: string;
errorMessage: string;
};
type ToastError = {
errorType: "TOAST";
errorCode: string;
errorMessage: string;
toastShowDuration: number;
}
type AlertError = {
errorType: "ALERT";
errorCode: string;
errorMessage: string;
onConfirm: () = > void;
};
판별자로 errorType 필드를 새로 정의했다.
type TextError = {
errorType: "TEXT";
errorCode: string;
errorMessage: string;
};
type ToastError = {
errorType: "TOAST";
errorCode: string;
errorMessage: string;
toastShowDuration: number;
}
type AlertError = {
errorType: "ALERT";
errorCode: string;
errorMessage: string;
onConfirm: () = > void;
};
type ErrorFeedbackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
{ errorType: "TEXT", errorCode: "100", errorMessage: "텍스트 에러" },
{
errorType: "TEXT",
errorCode: "999",
errorMessage: "잘못된 에러",
toastShowDuration: 3000, // Object literal may only specify known properties, and ‘toastShowDuration’ does not exist in type ‘TextError’
onConfirm: () => {},
},
{
errorType: "TOAST",
errorCode: "210",
errorMessage: "토스트 에러",
onConfirm: () => {}, // Object literal may only specify known properties, and ‘onConfirm’ does not exist in type ‘ToastError’
},
{
errorType: "ALERT",
errorCode: "310",
errorMessage: "얼럿 에러",
toastShowDuration: 5000, // Object literal may only specify known properties, and ‘toastShowDuration’ does not exist in type ‘AlertError’
},
];
기존 코드와의 차이점은, errorType의 존재밖에 없다. 하지만, 고유한 타입 구분자로 인해 Typescript는 각 객체가 어떤 타입에 속하는 지 명확히 식별할 수 있게 되었다.
errorType이 없던 코드에선, 각 객체가 어떤 타입에 속하는지 명시적으로 구분하지 않았다.이러한 판별자를 선정할때, 조건이 있다.
공식 깃허브의 이슈 탭
- 리터럴 타입이어야 한다.
- 판별자로 선정한 값에 적어도 하나 이상의 유닛 타입이 포함되어야 하며, 인스턴스화할 수 있는 타입은 포함되지 않아야 한다.
풀어서 설명하자면,
null, undefined, 리터럴 타입, true, 1 등 정확한 값을 나타내는 타입을 말한다.void, string, number와 같은 타입은 유닛타입으로 적용되지 않는다.interface A {
value: "a"; // unit type
answer: 1;
}
interface B {
value: string; // not unit type
answer: 2;
}
interface C {
value: Error; // instantiable type
answer: 3;
}
type Unions = A | B | C;
function handle(param: Unions) {
/** 판별자가 value일 때 */
param.answer; // 1 | 2 | 3
// ‘a’가 리터럴 타입이므로 타입이 좁혀진다.
// 단, 이는 string 타입에 포함되므로 param은 A 또는 B 타입으로 좁혀진다
if (param.value === "a") {
param.answer; // 1 | 2 return;
}
// 유닛 타입이 아니거나 인스턴스화할 수 있는 타입일 경우 타입이 좁혀지지 않는다
if (typeof param.value === "string") {
param.answer; // 1 | 2 | 3 return;
}
if (param.value instanceof Error) {
param.answer; // 1 | 2 | 3 return;
}
/** 판별자가 answer일 때 */
param.value; // string | Error
// 판별자가 유닛 타입이므로 타입이 좁혀진다
if (param.answer === 1) {
param.value; // ‘a’
}
}
위 예시에서, a만 유일한 유닛타입으로 타입이 좁혀지는 것을 볼 수 있다.
Exhaustiveness는 사전적으로 철저함, 완전함을 의미한다.
Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말한다.type ProductPrice = "10000" | "20000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "배민상품권 1만 원";
if (productPrice === "20000") return "배민상품권 2만 원";
else {
return "배민상품권";
}
};
여기까진 큰 문제가 없다고 생각할 수 있다.
하지만 새로운 상품권이 생겨 ProductPrice타입이 업데이트 되어야한다면?
type ProductPrice = "10000" | "20000" | "5000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "배민상품권 1만 원";
if (productPrice === "20000") return "배민상품권 2만 원";
if (productPrice === "5000") return "배민상품권 5천 원"; // 조건 추가 필요
else {
return "배민상품권";
}
};
이때 getProductName을 수정하지 않아도, 별도 에러를 반환하는게 아니기에 실수의 여지가 있다.
이떄 모든 타입에 대해 타입 검사를 강제하고 싶다면, 아래 방법이 있다.
type ProductPrice = "10000" | "20000" | "5000";
const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "배민상품권 1만 원";
if (productPrice === "20000") return "배민상품권 2만 원";
// if (productPrice === "5000") return "배민상품권 5천 원";
else {
exhaustiveCheck(productPrice);
// Error: Argument of type ‘string’ is not assignable to parameter of type ‘never’
return "배민상품권";
}
};
const exhaustiveCheck = (param: never) => {
throw new Error("type error!");
};
productPrice === "5000" 일 경우를 주석처리 했는데, 에러를 뱉었다.
exhaustiveCheck에서 5000일 경우의 분기처리를 진행하지 않았기에 발생한 것이다.Exhaustiveness Checking이라고 한다.exhaustiveCheck에선 never타입으로 선언하고 있다.else에 이 함수를 넣어 모든 타입에 대한 분기 처리를 강제했다.이처럼 타입에 대한 철저한 분기 처리가 필요하다면,
Exhaustiveness Checking을 활용해보길 바란다.