이번 글에서는 Typescript의 타입을 활용하는 방법들을 정리해보았습니다.
1) 조건부 타입
타입에서 조건에 따라 다른 타입을 반환해야 할 때 사용합니다. 타입스크립트의 조건부 타입은 자바스크립트의 삼항 연산자와 동일한 Condition ? A : B 형태를 가집니다.
(1) extends와 제네릭을 활용한 조건부 타입
extends는 타입 확장 뿐 아니라 조건부 타입 설정에서도 사용되고, 제네릭 타입에서는 한정자 역할로도 사용됩니다.
아래 코드들은 결제 수단과 관련된 타입입니다.
// 결제 수단 관련 타입 정의
interface Bank {
financialCode: string;
name: string;
// 기타 은행 관련 속성
}
interface Card {
financialCode: string;
name: string;
addCardType: boolean; // 카드사 앱으로 등록 가능 여부
// 기타 카드 관련 속성
}
// 제네릭과 extends를 활용한 조건부 타입
type PayMethod<T> = T extends Card
? { type: 'card'; provider: string; data: T }
: T extends Bank
? { type: 'bank'; accountNumber: string; data: T }
: never;
// 타입 적용 예시
type CardPayMethodType = PayMethod<Card>;
// { type: 'card'; provider: string; data: Card }
type BankPayMethodType = PayMethod<Bank>;
// { type: 'bank'; accountNumber: string; data: Bank }
Bank는 계좌를 이용한 결제 수단이며 고유코드인 financialCode, name 등을 가지고 있습니다. Card 타입과 다른점은 addCardType으로 카드사 앱을 사용해서 카드 정보를 등록할 수 있는지 구별해주는 속성있다는 점 입니다.
PayMethod 타입은 제네릭 타입으로 extends를 사용한 조건부 타입입니다.
PayMethod 타입을 사용해서 CardPayMethodType과 BankPayMethodType을 도출할 수 있습니다.
(2) 조건부 타입이 필요한 경우 예시 보기
아래 코드는 react-query를 활용한 예시로 계좌, 카드, 앱 카드 등 3가지 결제 수단 정보를 가져오는 API가 있고 각 API는 계좌, 카드, 앱 카드의 결제 수단 정보를 배열 형태로 반환한다고 가정하겠습니다.
3가지 API의 엔드포인트가 비슷하므로 서버 응답을 처리하는 공통 함수를 생성한 뒤, 해당 함수에 타입을 전달해 타입별로 처리하는 로직 코드를 살펴보며, 조건부 타입이 필요한 경우를 살펴보겠습니다.
// API 호출 함수 (결제 수단 정보를 가져오는 API)
async function fetchPaymentMethods<T>(
endpoint: string
): Promise<T[]> {
const response = await fetch(`/api/payment/${endpoint}`);
return response.json();
}
// 각 결제 수단 타입별 API 호출 함수
function useBankPaymentMethods() {
return useQuery<Bank[]>('banks', () =>
fetchPaymentMethods<Bank>('banks')
);
}
function useCardPaymentMethods() {
return useQuery<Card[]>('cards', () =>
fetchPaymentMethods<Card>('cards')
);
}
function useAppCardPaymentMethods() {
return useQuery<Card[]>('appCards', () =>
fetchPaymentMethods<Card>('app-cards')
);
}
(3) infer를 활용해서 타입 추론하기
extends를 사용할 땐 infer라는 키워드도 사용할 수 있습니다. 이를 적용할 경우 extends로 조건을 서술하고 infer로 타입을 추론하는 구조로 작성됩니다. 예시를 살펴보겠습니다.
// Promise 타입에서 내부 타입을 추출하는 유틸리티 타입
type UnpackPromise<T> = T extends Promise<infer K> ? K : T;
// 배열의 경우 요소 타입에 적용
type UnpackArrayPromise<T> = T extends (infer U)[]
? UnpackPromise<U>[]
: UnpackPromise<T>;
// 사용 예시
const cardPromise = Promise.resolve({ cardNumber: '1234-5678', name: 'John' });
type CardType = UnpackPromise<typeof cardPromise>; // { cardNumber: string; name: string; }
const promises = [Promise.resolve("Mark"), Promise.resolve(38)];
type PromiseResults = UnpackArrayPromise<typeof promises>; // (string | number)[]
UnpackPromise 타입은 Promise 안에 담긴 실제 데이터 타입을 꺼내주는 역할을 합니다.
Promise<string>이면 → string으로 변환하고, Promise가 아닌 타입은 → 원래 타입(T) 그대로 유지합니다.
// 예시
const promises = [Promise.resolve("Mark"), Promise.resolve(38)];
type Expected = UnpackPromise<typeof promises>; // string | number
Promise<infer K> 는 Promise의 반환 값을 추론해 해당 타입을 K로 한다는 의미입니다.
extends와 infer, 제네릭을 활용하면 타입을 조건에 따라 더 세밀하게 사용할 수 있습니다.
라우팅 타입 문제를 infer를 활용해 해결한 사례를 보며 자세히 살펴보겠습니다.
// 라우팅 정의를 위한 타입
interface RouteBase {
name: string;
path: string;
component: ComponentType;
}
export interface RouteItem {
name: string;
path: string;
component?: ComponentType;
pages?: RouteBase[];
}
// 실제 라우트 정의
export const routes: RouteItem[] = [
{
name: "기기 내역 관리",
path: "/device-history",
component: DeviceHistoryPage,
},
{
name: "헬멧 인증 관리",
path: "/helmet-certification",
component: HelmetCertificationPage,
},
// ... 기타 라우트들
];
// 메뉴 정의를 위한 타입
export interface SubMenu {
name: string;
path: string;
}
export interface MainMenu {
name: string;
path?: string;
subMenus?: SubMenu[];
}
export type MenuItem = MainMenu | SubMenu;
// 실제 메뉴 정의
export const menuList: MenuItem[] = [
{
name: "계정 관리",
subMenus: [
{ name: "기기 내역 관리", path: "/device-history" },
{ name: "헬멧 인증 관리", path: "/helmet-certification" },
],
},
{ name: "운행 관리", path: "/operation" },
// ... 기타 메뉴들
];
이 코드의 문제는 크게 2가지로 볼 수 있습니다.
1. name 속성이 모두 단순 string 타입으로 정의되어 있어서 오타가 있어도 타입 체크에서 감지되지 않습니다.
예: "기기 내역 관리"를 "기기 관리"로 잘못 작성해도 타입 오류가 발생하지 않습니다.
2. 라우팅(routes)과 메뉴(menuList)의 name 값이 정확히 일치해야 권한 확인이 제대로 작동합니다. 하지만 이 일치 여부가 타입 시스템에 의해 강제되지 않아 런타임 오류가 발생할 가능성이 있습니다.
이를 infer를 활용해 개선해보면,
// 📌 1. 메뉴 리스트를 불변 객체로 정의
export interface MainMenu {
name: string;
path?: string;
subMenus?: ReadonlyArray<SubMenu>; // ✨ ReadonlyArray로 변경 (불변성 강화)
}
export interface SubMenu {
name: string;
path: string;
}
export type MenuItem = MainMenu | SubMenu; // 👉 메인메뉴 또는 서브메뉴를 의미
// 🔒 as const를 사용해 불변 객체로 만듦 (이게 핵심!)
// as const 는 객체를 읽기 전용으로 만들고, 그 안의 모든 값을 리터럴 타입으로 변환
// 예: "기기 내역 관리"는 단순 string이 아닌, 정확히 "기기 내역 관리" 리터럴 타입이 됩니다.
export const menuList = [
{
name: "계정 관리", // 👆 이 name은 단순 그룹명
subMenus: [
{ name: "기기 내역 관리", path: "/device-history" }, // ⭐ 실제 권한명
{ name: "헬멧 인증 관리", path: "/helmet-certification" }, // ⭐ 실제 권한명
],
},
{ name: "운행 관리", path: "/operation" }, // ⭐ 실제 권한명
// ... 기타 메뉴들
] as const; // 💡 이 as const가 모든 문자열을 정확한 리터럴 타입으로 만들어줌
// 📌 2. 권한 이름을 추출하는 타입 정의 (🧙♂️ 마법의 타입 추론)
type UnpackMenuNames<T extends ReadonlyArray<MenuItem>> =
T extends ReadonlyArray<infer U> // 🔍 배열에서 각 요소 타입 U로 추출
? U extends MainMenu // 🤔 메인 메뉴인가?
? U["subMenus"] extends infer V // 🔍 메인 메뉴면 -> 서브메뉴가 있는지 확인
? V extends ReadonlyArray<SubMenu> // 🤔 서브 메뉴가 있는가?
? UnpackMenuNames<V> // 🔄 서브메뉴가 있으면 -> UnpackMenuNames<V> 서브메뉴로 다시 처리 (재귀)
: U["name"] // ✅ 서브메뉴 없으면 -> U["name"] (직접 권한으로 사용)
: never
: U extends SubMenu
? U["name"] // ✅ 서브메뉴면 -> U["name"] (직접 권한으로 사용)
: never
: never;
// 📌 3. 메뉴에서 추출한 권한 이름 타입
export type PermissionNames = UnpackMenuNames<typeof menuList>;
// 🎯 결과: "기기 내역 관리" | "헬멧 인증 관리" | "운행 관리"
// 📌 4. 라우팅 타입을 권한 이름 타입과 연결
interface RouteBase {
name: PermissionNames; // 🔗 string에서 PermissionNames로 변경 (타입 안전성 강화!)
path: string;
component: ComponentType;
}
export type RouteItem =
| { // 👇 페이지 그룹 (폴더 같은 개념)
name: string; // 📝 페이지 그룹명은 여전히 string (권한 검사 대상이 아님)
path: string;
component?: ComponentType;
pages: RouteBase[]; // 🔒 하위 페이지들은 권한 검사 대상
}
| { // 👇 단일 페이지
name: PermissionNames; // 🔒 단일 페이지는 권한 검사 대상
path: string;
component?: ComponentType;
};
혹시 너도? 나도?! 이해가 어려웠던 코드 정리
U["subMenus"] extends infer V
[ 설명 ]
U의 subMenus 속성이 있든 없든, 그 타입을 V라고 부르자라는 의미로 볼 수 있습니다.
V는 "subMenus의 타입이 무엇이든 간에 그것"을 나타냅니다.
MainMenu 타입에서 subMenus는 ReadonlyArray<SubMenu> | undefined가 될 수 있습니다 (optional이니까).
이 코드는 subMenus의 실제 타입을 V라는 변수에 담아두고, 다음 조건에서 사용합니다.
이 다음에 오는 조건 V extends ReadonlyArray<SubMenu>은 "V가 SubMenu의 배열이면"이라는 의미입니다. 즉, subMenus가 실제로 존재하는지 검사하는 것입니다.
간단히 정리하면:
- infer V로 subMenus의 타입을 V에 담고
- V가 SubMenu 배열이면 (즉, subMenus가 존재하면) → 서브메뉴들에서 권한을 추출
- 그렇지 않으면 (subMenus가 없으면) → 메인 메뉴의 name을 권한으로 사용
as const와 ReadonlyArray를 함께 사용한 이유?
예제 코드에서 as const와 ReadonlyArray를 함께 사용한 이유는 메뉴 및 권한 구조를 타입 시스템 수준에서 안전하게 관리할 수 있으며, 타입스크립트 컴파일러가 오타나 불일치를 감지할 수 있게 되기 때문입니다.
ReadonlyArray<SubMenu>
MainMenu 인터페이스에서 subMenus 속성을 읽기 전용으로 만들어 불변성 보장하고, 한 번 정의된 메뉴 구조가 런타임에 변경되지 않도록 합니다.
as const
menuList 전체를 불변 객체로 만들어 모든 문자열이 리터럴 타입으로 유지되도록 하고, 이를 통해 타입 추출 시 정확한 문자열 리터럴 타입을 얻을 수 있게 됩니다.
예: "기기 내역 관리" (문자열 리터럴 타입) vs string (일반 문자열 타입)
각각의 개념과 기능
as const 타입 단언
as const는 타입스크립트에서 값을 "리터럴 타입"으로 변환해주는 특별한 타입 단언(type assertion)입니다.
1. 값을 불변(readonly)으로 만듭니다.
객체의 모든 속성이 불변(readonly)이 됩니다.
배열이 불변(readonly) 튜플이 됩니다.
2. 값의 타입을 최대한 구체적인 리터럴 타입으로 변환합니다.
string → 정확한 문자열 리터럴(예: "기기 내역 관리")
number → 정확한 숫자 리터럴(예: 42)
객체 → 모든 속성이 리터럴 타입인 읽기 전용 객체
ReadonlyArray<T>
ReadonlyArray<T>는 타입스크립트에서 제공하는 기본 제네릭 타입으로, 요소를 추가, 제거 또는 변경할 수 없는 배열을 정의합니다.
1. 배열 내용을 변경할 수 없음
push(), pop(), shift(), unshift(), splice() 등의 메서드 사용 불가
배열 요소를 직접 수정(arr[0] = newValue) 불가
2. 읽기 전용 조작만 허용
map(), filter(), reduce() 등의 비파괴적 메서드는 사용 가능
length 속성 등 읽기는 가능
2) 템플릿 리터럴 타입 활용
타입 스크립트에서는 유니온 타입을 활용해 변수 타입을 특정 문자열로 지정할 수 있습니다.
타입스크립트 4.1부터 이를 확장하는 방법인 템플릿 리터럴 타입이 지원되기 시작했고, 이는 자바스크립트의 템플릿 리터럴 문법을 사용해 특정 문자열에 대한 타입을 선언할 수 있는 기능입니다.
HeaderTag 타입은 템플릿 리터럴 타입을 사용해 아래와 같이 선언할 수 있습니다.
// 적용 전
type HeaderTag = "h1" | "h2" | "h3"| "h4" | "h5";
// 적용 후
type HeadingNumber = 1 | 2 | 3 | 4 | 5;
type HeaderTag = `h${HeadingNumber}`;
템플릿 리터럴을 사용함으로써 더욱 읽기 쉬운 코드 작성이 가능하고, 코드를 재사용하고 수정하는데 용이한 타입을 선언할 수 있습니다.
Q. 그럼 최대로 추론할 수 있는 경우의 수는?
타입스크립트 컴파일러가 유니온을 추론하는데 시간이 오래 걸리면 비효율적이므로 타입스크립트가 타입을 추론하지 않고 에러를 내뱉을 때가 있습니다. 이 땐, 템플릿 리터럴 타입에 사용된 유니온 조합의 경우의 수가 너무 많지 않게 적절히 나누어 타입을 정의하는 것이 좋습니다.
타입스크립트는 템플릿 리터럴 타입에서 약 10,000개의 경우의 수까지 추론할 수 있습니다. 이 한계를 초과하면 "Type instantiation is excessively deep and possibly infinite.(타입 인스턴스화가 너무 깊거나 무한할 수 있습니다.)"라는 오류 메시지가 표시됩니다. 예를 들어, 각각 10개 항목을 가진 유니온 타입 4개를 조합하면 10^4 = 10,000개의 경우의 수가 생기는데, 이는 한계에 도달합니다. 실무에서는 경우의 수가 100개 이하가 되도록 유니온 타입을 분리하는 것이 좋습니다.
적용 예시
// 다양한 헤더 태그 타입 정의
type HeadingNumber = 1 | 2 | 3 | 4 | 5;
type HeaderTag = `h${HeadingNumber}`;
// 버튼 크기 타입 정의
type Size = 'small' | 'medium' | 'large';
type ButtonSize = `btn-${Size}`;
// 색상 변형 타입 정의
type Color = 'primary' | 'secondary' | 'success' | 'danger';
type ButtonVariant = `btn-${Color}` | `btn-outline-${Color}`;
// 조합된 버튼 클래스 타입
type ButtonClass = `${ButtonSize} ${ButtonVariant}`;
// 사용 예시
function createHeader(tag: HeaderTag, content: string) {
const element = document.createElement(tag);
element.textContent = content;
return element;
}
// 유효한 호출
createHeader('h1', '제목');
createHeader('h3', '소제목');
// 타입 오류 발생
// createHeader('h6', '잘못된 태그'); // Error: 'h6' is not assignable to type 'HeaderTag'
3) 커스텀 유틸리티 타입 활용
타입스크립트로 프로젝트를 진행하다보면, 타입을 정확하게 설정해야만 해당 컴포넌트, 함수의 안정성과 사용성을 높일 수 있지만 표현하기 힘든 타입을 마주할 때가 있습니다.
이럴 땐, 유틸리티 타입을 활용한 커스텀 유틸리티 타입을 제작해서 사용하면 됩니다.
(1) 유틸리티 함수를 활용해 styled-componets의 중복 타입 선언 피하기 : Pick, Omit
문제와 해결 과정을 살펴보면서 유틸리티 함수를 활용한 사례를 짚어보겠습니다.
Props 타입과 styled-componets 타입의 중복 선언 및 문제점
// 문제가 있는 방식: 중복 타입 선언
interface ButtonProps {
fontSize?: string;
backgroundColor?: string;
color?: string;
disabled?: boolean;
onClick: () => void;
}
// styled-components에서 또 다시 동일한 타입을 선언
const StyledButton = styled.button<{
fontSize?: string;
backgroundColor?: string;
color?: string;
disabled?: boolean;
}>`
font-size: ${({ fontSize }) => fontSize || '16px'};
background-color: ${({ backgroundColor }) => backgroundColor || '#ffffff'};
color: ${({ color }) => color || '#000000'};
opacity: ${({ disabled }) => disabled ? 0.5 : 1};
`;
const Button: React.FC<ButtonProps> = (props) => {
return <StyledButton {...props}>{props.children}</StyledButton>;
};
Pick, Omit 으로 개선하기
// Pick, Omit을 활용한 개선 버전
interface ButtonProps {
fontSize?: string;
backgroundColor?: string;
color?: string;
disabled?: boolean;
onClick: () => void;
}
// 👇 필요한 타입만 Pick으로 선택 👇
type StyledButtonProps = Pick<ButtonProps, 'fontSize' | 'backgroundColor' | 'color' | 'disabled'>;
// 👇 또는 Omit으로 필요 없는 타입 제외 👇
// type StyledButtonProps = Omit<ButtonProps, 'onClick'>;
const StyledButton = styled.button<StyledButtonProps>`
font-size: ${({ fontSize }) => fontSize || '16px'};
background-color: ${({ backgroundColor }) => backgroundColor || '#ffffff'};
color: ${({ color }) => color || '#000000'};
opacity: ${({ disabled }) => disabled ? 0.5 : 1};
`;
const Button: React.FC<ButtonProps> = (props) => {
return <StyledButton {...props}>{props.children}</StyledButton>;
};
이처럼 Pick이나 Omit 유틸리티 타입을 활용해 props에서 필요한 부분만 선택해 styled-components의 컴포넌트 타입을 정의하면 중복된 코드를 작성하지 않아도 되고 유지보수를 더욱 편리하게 할 수 있습니다.
자세히 알고가기 : Pick과 Omit
Pick 과 Omit은 각각 아래의 경우에 사용합니다.
- Pick: 필요한 속성이 소수일 때 (몇 개만 선택)
- Omit: 제외할 속성이 소수일 때 (몇 개만 제외)
Pick
Pick<T, K>: T 타입에서 K로 지정된 속성만 선택하여 새로운 타입을 구성합니다. 마치 객체에서 필요한 속성만 '골라내는' 느낌입니다.
예: Pick<User, 'id' | 'name'> - User 타입에서 id와 name 속성만 가진 새 타입 생성
// 예시: User 타입에서 id와 name만 필요한 경우
interface User {
id: number;
name: string;
email: string;
password: string;
}
// User에서 id와 name만 가진 타입 생성
type UserProfile = Pick<User, 'id' | 'name'>;
// 결과: { id: number; name: string; }
Omit
Omit<T, K>: T 타입에서 K로 지정된 속성을 제외한 나머지로 새로운 타입을 구성합니다. 마치 객체에서 특정 속성을 '잘라내는' 느낌입니다.
예: Omit<User, 'password'> - User 타입에서 password를 제외한 모든 속성을 가진 새 타입 생성
// 예시: User 타입에서 password는 제외하고 싶은 경우
interface User {
id: number;
name: string;
email: string;
password: string;
}
// User에서 password를 제외한 타입 생성
type SafeUser = Omit<User, 'password'>;
// 결과: { id: number; name: string; email: string; }
(2) PickOne 유틸리티 함수
타입스크립트에는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때, 아래 경우와 같이 타입 검사가 제대로 진행되지 않을 수도 있습니다.
// 👇 두 가지 객체 타입 정의
type Card = { card: string };
type Account = { account: string };
// 👇 문제 상황: Card 또는 Account 중 하나만 받기 원함
function withdraw(type: Card | Account) { /* ... */ }
// ❌ 문제: 두 속성을 모두 포함한 객체도 타입 검사를 통과함
withdraw({ card: "hyundai", account: "hana" }); // 에러가 발생해야 하지만 발생하지 않음!
왜 타입 에러가 발생하지 않을까요? 그 이유는 집합의 관점에서 Card | Account는 합집합이기 때문입니다.
- { card: string }도 허용 ✅
- { account: string }도 허용 ✅
- { card: string, account: string }도 허용 ✅ (에러가 발생하지 않는 주원인)
이는 크게 2가지 방식으로 에러를 관리할 수 있습니다.
[ 방식 1 ] 식별 가능한 유니온 (Discriminated Unions)
식별 가능한 유니온을 사용해, type 값을 식별자로 구분 가능하게 만드는 방법 입니다.
하지만 식별자를 일일이 추가해야 한다는 번거로움이 있습니다.
// 👇 type 속성을 추가하여 구분 가능하게 만듦
type Card = {
type: "card"; // 👈 식별자
card: string;
};
type Account = {
type: "account"; // 👈 식별자
account: string;
};
function withdraw(type: Card | Account) { /* ... */ }
// ✅ 정확한 타입만 전달 가능
withdraw({ type: "card", card: "hyundai" });
withdraw({ type: "account", account: "hana" });
[ 방식 2 ] PickOne 유틸리티 타입
아래 코드처럼 여러 속성 중 딱 하나의 속성만 허용하는 PickOne 타입을 만들어 사용하는 방법 입니다.
// 🧩 최종 PickOne 타입 (두 가지 타입의 교차 타입)
type PickOne<T> = One<T> & ExcludeOne<T>;
// 👇 Part 1: One<T> - 객체의 키-값 쌍 중 하나를 선택
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
// 👇 Part 2: ExcludeOne<T> - 선택된 키 외에 다른 키를 undefined로 설정
type ExcludeOne<T> = {
[P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];
코드별로 이해하기
Part 1. One<T>
type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
// ① 매핑된 타입 ② 레코드 생성 ③ 인덱싱
1️⃣ [P in keyof T]: T의 각 속성 키를 순회합니다.
2️⃣ Record<P, T[P]>: 키 P와 원래 값 T[P]를 가진 객체 타입을 만듭니다.
3️⃣ [keyof T]: 만들어진 객체 타입에서 T의 키로 접근합니다.
// 👉 예시: Card 타입에 One 적용
type Card = { card: string };
// 👉 One<Card>의 단계별 변환:
// type One<T> = { [P in keyof T]: Record<P, T[P]> }[keyof T];
// 1️⃣ { [P in keyof Card]: Record<P, Card[P]> }
// 2️⃣ { card: Record<'card', string> }
// 3️⃣ { card: { card: string } }[keyof Card]
// 4️⃣ { card: string }
const one: One<Card> = { card: "hyundai" }; // ✅
Part2. ExcludeOne<T>
type ExcludeOne<T> = {
[P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];
// ① 매핑 ②옵셔널 ③레코드 ④키 제외 ⑤인덱싱
1️⃣ [P in keyof T]: T의 각 속성 키를 순회합니다.
2️⃣ Exclude<keyof T, P>: 현재 키 P를 제외한 모든 키를 선택합니다.
3️⃣ Record<..., undefined>: 선택된 키들의 값을 undefined로 설정합니다.
4️⃣ Partial<...>: 모든 속성을 선택적(옵셔널)으로 만듭니다.
5️⃣ [keyof T]: 만들어진 객체 타입에서 T의 키로 접근합니다.
// 👉 예시: Card 타입에 ExcludeOne 적용
type Card = { card: string, other: number };
// 👉 ExcludeOne<Card>의 단계별 변환 (card 키 기준):
// type ExcludeOne<T> = {[P in keyof T]: Partial<Record<Exclude<keyof T, P>, undefined>>}[keyof T];
// 1️⃣ Exclude<keyof Card, 'card'> = 'other'
// 2️⃣ Record<'other', undefined> = { other: undefined }
// 3️⃣ Partial<{ other: undefined }> = { other?: undefined }
// 4️⃣ 최종 결과 = { card: string, other?: undefined }
// 👉 결과적으로 other 속성이 있어도 그 값이 undefined인 경우만 허용
PickOne 적용 결과
이렇게 PickOne 타입을 아래처럼 사용하면 여러 객체 타입 중 정확히 하나만 선택되도록 강제할 수 있습니다.
type Card = { card: string };
type Account = { account: string };
// 👇 PickOne을 사용한 함수 정의
function withdraw(type: PickOne<Card & Account>) { /* ... */ }
// ✅ 정상 동작: 하나의 속성만 있는 객체
withdraw({ card: "hyundai" });
withdraw({ account: "hana" });
// ❌ 타입 에러: 두 속성 모두 있는 객체
withdraw({ card: "hyundai", account: "hana" }); // 타입 에러 발생
(2) NonNullable 유틸리티 함수
타입스크립트에서 null이나 undefined를 처리하기 위해 매번 if문으로 체크하는 것은 번거롭습니다. 이는 아래처럼 NonNullable 이라는 유틸리티 타입을 사용해 타입 가드 함수를 만들어 사용함으로써 해결할 수 있습니다.
이 방식은 특히 API 응답이나 비동기 작업에서 null 값이 섞여 있을 때 유용합니다.
특히 여러 API 호출을 병렬로 처리할 때 일부 호출이 실패해도 전체 로직이 중단되지 않도록 해야 합니다. 이 예시에서는 이런 상황을 NonNullable 타입 가드로 해결하는 방법을 자세히 살펴보겠습니다.
NonNullable 유틸리티 타입
// 👇 타입스크립트 내장 유틸리티 타입
type NonNullable<T> = T extends null | undefined ? never : T;
// 🔍 T가 null이나 undefined면 never 반환, 아니면 T 그대로 반환
NonNullable 타입 가드 함수 구현
타입 가드 함수 구현은 아래와 같이 해볼 수 있습니다.
// 👇 null/undefined 체크 함수 (타입 가드)
function NonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
// 💡 is 키워드: 함수 반환값이 true이면 타입이 NonNullable<T>로 좁혀짐
실제 활용 예시: Promise.all에서 활용하기
예시 코드
API 요청 함수 (에러 시 null 반환)
타입 시그니처가 Promise<AdCampaign[] | null>로 선언되어 있어, API 호출이 성공하면 광고 목록을, 실패하면 null을 반환합니다.
오류가 발생하더라도 애플리케이션이 중단되지 않고 계속 실행되도록 하는 방어적 프로그래밍 기법입니다. null로 에러를 처리하는 것은 이후 처리 과정에서 에러를 구분할 수 있게 해주지만, 타입 안전성을 위한 추가 작업이 필요합니다.
class AdCampaignAPI {
static async operating(shopNo: number): Promise<AdCampaign[]> {
try {
// 👇 API 호출
return await fetch(`/ad/shopNumber=${shopNo}`);
} catch (error) {
// ⚠️ 에러 발생 시 null 반환
return null;
}
}
}
여러 상점의 광고 정보 가져오기
Promise.all은 모든 프로미스가 해결될 때까지 기다린 후 결과 배열을 반환합니다.
각 API 호출은 AdCampaign[] 또는 null을 반환할 수 있으므로, 결과 배열의 타입은 Array<AdCampaign[] | null>이 됩니다.
이 타입은 "각 요소가 광고 목록이거나 null일 수 있는 배열"을 의미합니다.
이 상태에서 배열을 순회하면 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))
);
// 🔍 타입: Array<AdCampaign[] | null> (null 포함 가능성)
일반적인 필터링 방식은 조건에 맞는 요소만 포함한 새 배열을 반환합니다. !!shop은 shop이 null이 아닐 때만 true가 되므로, null 값은 필터링됩니다.
하지만 타입스크립트는 이 필터링을 인식하지 못합니다. 반환 타입은 여전히 Array<AdCampaign[] | null>입니다.
이는 타입스크립트가 일반 JavaScript 함수의 복잡한 필터링 로직을 완전히 이해하지 못하기 때문입니다.
따라서 타입 시스템은 여전히 배열 요소에 null이 있을 수 있다고 가정합니다.
// ❌ 문제가 있는 방식
const filteredAds = shopAdCampaignList.filter((shop) => !!shop);
// 🔍 타입: Array<AdCampaign[] | null> (여전히 null 가능성 있음)
[ 개선 ] NonNullable 함수로 필터링 (타입 안전)
// 👇 타입 가드 함수 정의
function NonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
// ✅ NonNullable 함수로 필터링
const shopAds = shopAdCampaignList.filter(NonNullable);
// 🔍 타입: Array<AdCampaign[]> (null 가능성 제거됨)
타입 가드 함수 NonNullable은 값이 null 또는 undefined가 아닌지 확인합니다. value is NonNullable<T> 구문은 TypeScript에게 "이 함수가 true를 반환하면 값이 NonNullable<T> 타입임을 보장한다"고 알려줍니다.
이렇게 하면 TypeScript는 filter 후에 배열에서 null과 undefined가 제거되었다는 것을 이해할 수 있습니다. 결과적으로 shopAds의 타입은 Array<AdCampaign[]>으로 정확하게 추론됩니다.
NonNullable 사용 장점 정리
NonNullable을 사용하면 컴파일러가 null이 제거되었음을 인식하여 타입 오류를 더 정확히 감지합니다.
매번 if (value !== null && value !== undefined) 검사를 작성할 필요가 없어 코드가 긴결해지고, 추후 코드를 수정할 때 null 체크를 누락할 위험이 줄어듭니다.
// ❌ NonNullable 없이 사용하면
shopAdCampaignList.forEach(ads => {
if (ads) { // null 체크 필요
ads.forEach(ad => console.log(ad.title));
}
});
// ✅ NonNullable 사용 후
shopAds.forEach(ads => {
// null 체크 불필요! 타입스크립트가 ads가 항상 AdCampaign[]임을 알고 있음
ads.forEach(ad => console.log(ad.title));
});
4) 불변 객체 타입으로 활용
많은 프로젝트에서 상수값(색상, 크기 등)을 객체로 관리합니다. 이런 객체의 키를 함수나 컴포넌트에서 사용할 때 문제가 발생할 수 있습니다. 이는 불변 객체 타입을 지정해 해결할 수 있습니다.
객체의 키를 문자열로 다룰 때 발생할 수 있는 문제를 살펴보겠습니다.
// 👇 색상 정보를 담은 객체
const colors = {
red: "#F45452",
green: "#0C952A",
blue: "#1A7CFF",
};
// ❌ 문제가 있는 접근 방식: string 타입 사용
const getColorHex = (key: string) => colors[key];
// 🔍 반환 타입: any (타입 안전성 없음)
// 🚫 "purple" 같은 존재하지 않는 키도 전달 가능
위 코드의 문제는 getColorHex 함수가 any 타입을 반환한다는 점과 존재하지 않는 키를 전달해도 컴파일 시 오류가 발생하지 않는 다는 것입니다. (더불어 자동 완성 지원이 되지 않습니다.)
이럴 땐, keyof와 as const로 객체를 불변으로 만들어 타입 안전성을 확보할 수 있습니다.
[ step 1 ] as const로 객체를 불변으로 만들기
// ✅ as const로 객체를 불변으로 선언
const colors = {
red: "#F45452",
green: "#0C952A",
blue: "#1A7CFF",
} as const;
// 🔍 각 값이 정확한 문자열 리터럴 타입으로 변환됨
[ step 2 ] keyof와 typeof로 객체 키 타입 추출하기
// ✅ 객체 키 타입 추출
type ColorKey = keyof typeof colors;
// 🔍 결과: "red" | "green" | "blue"
// 👇 타입 안전한 함수 구현
const getColorHex = (key: ColorKey) => colors[key];
// 🔍 반환 타입: "#F45452" | "#0C952A" | "#1A7CFF" (정확한 타입)
const vs as const의 차이점 정리
const vs as const의 차이점
const와 as const는 다른 목적으로 사용됩니다:
const (JavaScript의 상수 선언)
const colors = { red: "#F45452", green: "#0C952A" }; // 타입: { red: string; green: string; }
- const는 변수 자체가 재할당되지 않도록 합니다.
- 그러나 객체의 내부 속성은 여전히 변경 가능합니다 (colors.red = "새로운색상" 가능)
- 타입스크립트는 값을 넓은 타입(string, number 등)으로 추론합니다.
as const (TypeScript의 타입 단언)
const colors = { red: "#F45452", green: "#0C952A" } as const;
// 타입: { readonly red: "#F45452"; readonly green: "#0C952A"; }
- as const는 객체 전체를 **깊은 수준까지 불변(readonly)**으로 만듭니다.
- 객체의 내부 속성도 변경 불가능해집니다 (colors.red = "새로운색상" 불가능)
- 타입스크립트는 값을 정확한 리터럴 타입으로 추론합니다.
리터럴 타입 vs string 타입의 차이
// string 타입
let color1: string = "red";
color1 = "anything"; // ✅ OK
// 리터럴 타입
let color2: "red" = "red";
color2 = "blue"; // ❌ 오류: "blue" 타입은 "red" 타입에 할당할 수 없습니다
string 타입
- 모든 문자열 값을 허용합니다.
- 어떤 문자열이든 할당 가능합니다.
- 타입 안전성이 낮습니다.
리터럴 타입
- 정확히 지정된 값만 허용합니다.
- 다른 값은 할당할 수 없습니다.
- 타입 안전성이 높습니다.
객체에서 리터럴 타입의 의미
// as const 없음
const theme = { fontSize: { small: "12px" } };
theme.fontSize.small = "14px"; // ✅ OK
// 타입: { fontSize: { small: string } }
// as const 있음
const themeConst = { fontSize: { small: "12px" } } as const;
themeConst.fontSize.small = "14px"; // ❌ 오류: readonly 속성을 할당할 수 없음
// 타입: { readonly fontSize: { readonly small: "12px" } }
5) Record 원시 타입 키 개선하기
타입스크립트에서 객체의 키-값 관계를 정의할 때 Record<K, V> 타입을 자주 사용합니다.
그러나 키 타입을 string이나 number 같은 원시 타입으로 지정하면 타입 안전성이 저하된다는 문제가 있습니다.
// 👇 문제가 있는 방식: 키 타입이 너무 광범위함
type Category = string; // 😕 모든 문자열이 허용됨
interface Food {
name: string;
// ... 기타 속성들
}
// 👇 음식 카테고리별 목록
const foodByCategory: Record<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
// ❌ 존재하지 않는 키에 접근해도 타입 오류가 없음
foodByCategory["양식"]; // 🔍 타입: Food[] (실제로는 undefined)
// ❌ 런타임 오류 발생!
foodByCategory["양식"].map((food) => console.log(food.name));
// 🚫 Uncaught TypeError: Cannot read properties of undefined (reading 'map')
이는 2가지 방법으로 개선해볼 수 있습니다.
[ 방법 1 ] 옵셔널 체이닝 사용하기
간단한 해결책으로 옵셔널 체이닝(?.)을 사용할 수 있습니다.
// ✅ 옵셔널 체이닝으로 런타임 오류 방지
foodByCategory["양식"]?.map((food) => console.log(food.name));
// 👆 undefined일 경우 아무 동작 없이 넘어감
옵셔널 체이닝(?.)은 객체 속성에 안전하게 접근하는 방법으로, 객체가 null 또는 undefined이면 → 즉시 undefined 를 반환하고, 객체가 존재하면 → 정상적으로 속성에 접근합니다.
그러나 이 방식은 아래와 같은 단점이 있습니다.
1) 개발자가 옵셔널 체이닝을 잊어버리더라도 타입 시스템이 오류를 표시하지 않습니다.
이 경우, 타입스크립트는 foodByCategory["양식"]의 타입을 Food[]로 추론합니다. (실제론 undefined)
2) 모든 접근마다 개발자가 옵셔널 체이닝을 기억해서 사용해야 하고, 한 번이라도 빼먹으면 런타임 오류 발생.
3) 코드만 봤을 때 해당 키가 존재하지 않을 수 있다는 의도가 타입에 표현되지 않습니다.
[ 방법 2 ] PartialRecord 타입 정의하기
그래서 더 좋은 해결책은 키가 존재하지 않을 수 있음을 타입 시스템에 명시하는 것입니다.
그 방법이 PartialRecord 을 사용하는 것입니다.
// 👇 부분적인 레코드 타입 정의
type PartialRecord<K extends string | number | symbol, T> = {
[P in K]?: T;
};
// 👇 개선된 방식
const foodByCategory: PartialRecord<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "뚝배기 불고기" }],
일식: [{ name: "초밥" }, { name: "텐동" }],
};
개선함으로써 얻을 수 있는 효과
// 🔍 이제 타입이 Food[] | undefined로 정확하게 추론됨
foodByCategory["양식"];
// ❌ 타입 오류가 발생 (컴파일 시 문제 감지)
foodByCategory["양식"].map((food) => console.log(food.name));
// 🚫 "Object is possibly 'undefined'" 오류 발생
// ✅ 옵셔널 체이닝 사용 필요성을 타입 시스템이 알려줌
foodByCategory["양식"]?.map((food) => console.log(food.name)); // OK
이렇게 PartialRecord 사용을 하면, 없는 키에 접근할 때 undefined 가능성을 타입 시스템이 인식하기 때문에 런타임 전에 오류를 발견할 수 있으며, 옵셔널 체이닝과 같은 안전한 방법으로 사용하도록 유도합니다.
이 방법은 외부 API 데이터처럼 모든 키가 항상 존재한다고 보장할 수 없는 객체를 다룰 때 특히 유용합니다.
따라서, 아래와 같이 PartialRecord 를 사용해야하는 이유를 정리해볼 수 있습니다.
1) 개발자가 옵셔널 체이닝을 빼먹으면 컴파일러가 즉시 오류 표시함으로써 런타임 오류를 미리 방지할 수 있습니다.
2) 타입 자체가 "이 객체의 키는 존재하지 않을 수 있다"는 정보를 담고 있기 때문에 의도 전달이 명확해집니다.
3) 프로젝트 전체에서 일관된 타입 체크가 가능합니다.
전체적으로 요약하면,
옵셔널 체이닝만 사용하는 것은 "개발자가 항상 기억해야 하는 방어적 코딩" 방식이고,
PartialRecord를 사용하는 것은 "타입 시스템이 자동으로 체크해주는 예방적 코딩" 방식이라고 볼 수 있습니다.