TypeScript Mapped Types와 Conditional Types로 유틸리티 타입 마스터하기
TypeScript의 Mapped Types와 Conditional Types를 깊이 이해하고, 이를 활용하여 Partial, Required를 넘어선 고급 유틸리티 타입을 직접 설계하는 방법을 알아봅니다. 재귀적 타입 변환과 Key Remapping 기법을 포함한 실전 예제를 통해 타입 시스템의 유연성을 극대화하는 방법을 제시합니다.
Mapped Types와 Conditional Types, TypeScript 고급 유틸리티 타입의 핵심
TypeScript를 사용하면서 자주 접하는 Partial, Required, Readonly와 같은 유틸리티 타입들은 코드의 안정성과 가독성을 크게 향상시킵니다. 이러한 타입들은 Mapped Types와 Conditional Types라는 강력한 기능을 기반으로 설계됩니다. 이번 글에서는 이 두 가지 고급 기능을 깊이 파고들어, 직접 고급 유틸리티 타입을 설계하는 방법을 알아보겠습니다. 복잡한 타입 조작이 필요한 상황에서 여러분의 TypeScript 활용 능력을 한 단계 끌어올릴 것입니다.
Mapped Types: 기존 타입을 변환하는 설계도
Mapped Types는 기존 타입의 각 프로퍼티를 순회하며 새로운 타입을 정의하는 방식입니다. 마치 각 멤버에게 명령을 내리듯, 새로운 구조를 만들어냅니다.
가장 기본적인 형태는 다음과 같습니다.
1type MyMappedType<T> = { 2 [P in keyof T]: T[P]; 3};
여기서 keyof T는 타입 T의 모든 프로퍼티 키들의 유니온 타입을 반환합니다. P in keyof T는 T의 각 프로퍼티 키를 순회하며, T[P]는 해당 프로퍼티의 타입을 참조합니다. 이 기본적인 구조를 응용하여 Partial, Required, Readonly 등을 구현할 수 있습니다.
Partial<T> 구현 예시:
1type MyPartial<T> = { 2 [P in keyof T]?: T[P]; 3}; 4 5// 사용 예시 6interface User { 7 id: number; 8 name: string; 9 email: string; 10} 11 12type PartialUser = MyPartial<User>; 13// PartialUser는 { id?: number; name?: string; email?: string; } 와 같습니다.
? 연산자는 프로퍼티를 선택적으로 만들어 줍니다. 이처럼 Mapped Types는 기존 타입의 구조를 그대로 유지하면서 프로퍼티의 속성(접근 가능성, 선택성 등)을 변경하는 데 탁월합니다.
Conditional Types: 타입의 조건부 로직
Conditional Types는 타입 수준에서 if-else 문과 유사한 로직을 수행하게 해줍니다. 특정 조건을 만족하는지에 따라 다른 타입을 반환합니다.
기본 구문은 다음과 같습니다.
1Type1 extends Type2 ? TrueType : FalseType;
Type1이 Type2를 확장(할당 가능)하면 TrueType을, 그렇지 않으면 FalseType을 반환합니다. 이 기능은 제네릭 타입과 함께 사용될 때 그 진가를 발휘합니다.
Exclude<T, U> 구현 예시 (TypeScript 2.8+):
Exclude<T, U>는 타입 T에서 타입 U에 할당 가능한 모든 멤버를 제거한 타입을 반환합니다. 이는 분리(Distributive) 조건부 타입의 활용을 잘 보여줍니다.
1type MyExclude<T, U> = T extends U ? never : T; 2 3// 사용 예시 4type T = 'a' | 'b' | 'c'; 5type U = 'a' | 'd'; 6 7type Result = MyExclude<T, U>; 8// Result는 'b' | 'c' 가 됩니다.
MyExclude<T, U>에서 T가 유니온 타입('a' | 'b' | 'c')일 때, 조건부 타입은 유니온의 각 멤버에 대해 개별적으로 적용됩니다. 즉, 'a' extends 'a' | 'd'는 true이므로 never가 되고, 'b' extends 'a' | 'd'는 false이므로 'b'가 됩니다. 'c' extends 'a' | 'd' 역시 false이므로 'c'가 됩니다. 최종적으로 never | 'b' | 'c'는 'b' | 'c'로 축약됩니다. 이처럼 분리 동작은 유니온 타입을 다룰 때 매우 유용합니다.
Mapped Types와 Conditional Types의 결합: 고급 유틸리티 타입 설계
이 두 기능을 결합하면 매우 복잡하고 강력한 유틸리티 타입을 설계할 수 있습니다. 예를 들어, 특정 조건에 따라 프로퍼티의 타입을 동적으로 변경하는 유틸리티 타입을 만들어 봅시다.
OmitByType<T, U> 설계:
OmitByType<T, U>는 원본 타입 T에서 특정 타입 U를 가진 프로퍼티들을 제거하는 유틸리티 타입을 만듭니다. Omit<T, K>가 프로퍼티 키(문자열 리터럴)를 기준으로 제거하는 것과 달리, OmitByType은 프로퍼티의 '타입'을 기준으로 제거합니다.
1// `T`의 모든 프로퍼티 키를 순회하며, 해당 프로퍼티의 타입이 `U`에 할당 가능하지 않을 경우에만 유지합니다. 2type MyOmitByType<T, U> = { 3 [P in keyof T as T[P] extends U ? never : P]: T[P]; 4}; 5 6// 사용 예시 7interface Person { 8 name: string; 9 age: number; 10 isStudent: boolean; 11} 12 13// number 타입을 가진 프로퍼티 (age)를 제거합니다. 14type PersonWithoutAge = MyOmitByType<Person, number>; 15// PersonWithoutAge는 { name: string; isStudent: boolean; } 와 같습니다. 16 17// string 또는 boolean 타입을 가진 프로퍼티 (name, isStudent)를 제거합니다. 18type PersonWithoutStringOrBool = MyOmitByType<Person, string | boolean>; 19// PersonWithoutStringOrBool는 { age: number; } 와 같습니다.
이 예제에서 [P in keyof T as ... ] 부분은 Mapped Types의 key remapping 기능을 사용합니다. as 키워드 뒤에 오는 표현식은 새로운 프로퍼티 키를 결정하며, T[P] extends U ? never : P는 프로퍼티의 타입(T[P])이 U에 할당 가능하다면 never를 반환하여 해당 프로퍼티를 제거하고, 그렇지 않으면 원래 프로퍼티 키 P를 유지하도록 합니다. Conditional Types가 프로퍼티 타입을 조건부로 필터링하는 역할을 수행합니다.
실전 응용: DeepPartial과 DeepReadonly
Partial이나 Readonly는 최상위 프로퍼티에만 적용됩니다. 중첩된 객체 타입까지 재귀적으로 적용하려면 Mapped Types와 Conditional Types를 재귀적으로 활용해야 합니다. (TypeScript 4.0 이상에서는 infer 키워드를 활용하여 더 간결하게 구현 가능하지만, 여기서는 기본 개념에 집중합니다.)
DeepPartial<T>를 구현한다고 가정해 봅시다. 각 프로퍼티에 대해, 그 프로퍼티가 객체 타입이면 재귀적으로 DeepPartial을 적용하고, 객체가 아니라면 ?를 붙여 선택적으로 만듭니다.
1// Simplified version for demonstration. Full implementation often uses infer. 2type MyDeepPartial<T> = { 3 [P in keyof T]?: T[P] extends Record<string, any> ? MyDeepPartial<T[P]> : T[P]; 4}; 5 6// 사용 예시 7interface DeepSettings { 8 theme: { 9 colors: { 10 primary: string; 11 secondary: string; 12 }; 13 fonts: string; 14 }; 15 user: { 16 name: string; 17 age: number; 18 }; 19} 20 21type DeepPartialSettings = MyDeepPartial<DeepSettings>; 22/* 23DeepPartialSettings는 다음과 유사한 구조를 가집니다: 24{ 25 theme?: { 26 colors?: { 27 primary?: string; 28 secondary?: string; 29 }; 30 fonts?: string; 31 }; 32 user?: { 33 name?: string; 34 age?: number; 35 }; 36} 37*/
이 예제에서 T[P] extends Record<string, any> ? MyDeepPartial<T[P]> : T[P]는 프로퍼티의 타입이 객체(Record<string, any>)인지 확인하고, 객체라면 MyDeepPartial을 재귀적으로 호출하여 중첩된 객체에도 Partial을 적용합니다. 객체가 아니면 그대로 타입을 유지합니다. 마지막으로 ?를 통해 모든 프로퍼티를 선택적으로 만듭니다.
DeepReadonly도 유사한 원리로 구현할 수 있습니다. readonly 키워드를 추가하는 방식으로 차이를 둡니다.
결론: 타입 시스템의 유연성을 극대화하는 도구
Mapped Types와 Conditional Types는 TypeScript에서 복잡한 데이터 구조와 타입 간의 관계를 표현하고 조작할 수 있게 해주는 강력한 기능입니다. 이 두 가지를 능숙하게 사용하면 Partial, Required, Pick, Omit 등을 넘어서는 매우 정교하고 재사용 가능한 유틸리티 타입을 직접 설계할 수 있습니다. 이는 코드의 안정성을 높이고, 반복적인 타입 정의 작업을 줄이며, 결과적으로 개발 생산성을 향상시키는 데 크게 기여합니다. 여러분의 TypeScript 프로젝트에서 이러한 고급 타입 패턴을 적극적으로 활용해 보시기 바랍니다.