서론
이 글은 naverD2의 아티클을 재구성한 글임을 밝힙니다.
다중 중첩된 객체를 평탄화하는 유틸함수 flattenObject가 있습니다.
아래 코드에서 아쉬운 점은 반환 타입이 any로 정의되어 있다는 점인데요. 오늘 포스팅의 내용은 이 flattenObject의 반환 타입을 추론하는 과정에 대한 설명입니다. 원문에서는 문제 해결의 관점에서 상향식으로 서술되어있지만 이 글에서는 기존 프로젝트에 작성된 복잡한 타입 정의를 분석하는 상황을 가정하고 하향식으로 설명해보도록 하겠습니다.
function flattenObject(obj:any, result: any = {}):any {
for (const key in obj) {
if(typeof obj[key] === 'object' && obj[key] && !(obj[key] instanceof Array)) {
flattenObject(obj[key], result)
} else {
result[key] = obj[key]
}
}
return result
}
저희 목표는 아래 이미지와 같습니다.
중첩된 객체 구조에서 최종적으로 값이 할당된 필드를 최상위 레벨로 평탄화하는 것이 목적입니다.
저희가 목표하는 최종 코드는 아래와 같습니다. 실무 프로젝트에서 이런 타입 정의를 만나게 되면 이해가 쉽지 않을 것 같습니다. 오늘 목표는 이 타입정의를 하나씩 분석해가면서 복잡한 형태의 타입 추론 과정에 대한 이해능력을 기르는 것입니다. 만약 아래 코드에서 타입 정의가 순조롭게 이해되시는 분은 뒤로가기를 누르셔도 될 것같습니다!
type FilterPrimitiveKeys<T, K> = K extends keyof T
? T[K] extends null | unknown[]
? K
: T[K] extends object
? never
: K
: never
type FilterNestedKeys<T, K> = K extends keyof T
? T[K] extends null | unknown[]
? never
: T[K] extends object
? K
: never
: never
type Values<T extends object> = T[keyof T]
type NestedObject<T extends object> = {
[K in FilterNestedKeys<T, keyof T>]: T[K]
}
type RecursionHelper<T> = T extends object ? FlattendObject<T> : never
type ToIntersection<T> = (T extends any ? (_: T) => void : never) extends (
_: infer S,
) => void
? S
: never
type UnwrappedObject<T extends object> = ToIntersection<
RecursionHelper<Values<NestedObject<T>>>
>
type SimpleFlattendObject<T extends object> = {
[K in FilterPrimitiveKeys<T, keyof T>]: T[K]
}
type FlattendObject<T extends object> = SimpleFlattendObject<T> &
UnwrappedObject<T>
type Roll<T> = {
[K in keyof T]: T[K]
} & {}
function flattenObject<T extends object>(
obj: T,
result: any = {},
): Roll<FlattendObject<T>> {
for (const key in obj) {
if (
typeof obj[key] === 'object' &&
obj[key] &&
!(obj[key] instanceof Array)
) {
flattenObject(obj[key] as object, result)
} else {
result[key] = obj[key]
}
}
return result
}
하위 섹션부터는 falttenObject함수를 기준으로 타입 정의를 하향식으로 따라가면서 만나는 유틸 타입에 대해 분석하고 이것이 어떻게 결합되는지 알아보면서 최종 목표인 FlattendObect 타입에 대해 이해해보겠습니다.
1. Roll
언뜻 목적을 이해할 수 없는 Roll 타입의 역할은 무엇일까요?
type Roll<T> = {
[K in keyof T]: T[K]
} & {}
Roll은 유니온 타입의 개별 필드를 낱낱이 보여주는 역할을 합니다.
예를 들어 아래와 같은 코드에서 C 타입에 대한 결과는 IDE에서 A & B로 출력됩니다. 경우에 따라 A 타입과 B타입의 하위 필드가 IDE에서 보여지길 원할 수 있습니다. 그런 니즈를 충족시키는 것이 Roll의 역할입니다.
이 유틸 타입은 Matt Pocock의 트윗에서 가져왔습니다. 아쉽게도 동작 원리에 대한 문법적 해석이 불가능합니다. 이 동작은 타입스크립트의 내부 구현을 분석해야 알 수 있는 이해할 수 있고 일종의 관용적 표현으로 알고 계시면 될 것 같습니다.
type A = {
a: 'a'
aa: 'aa'
}
type B = {
b: 'b'
bb: 'bb'
}
type C = A & B
위 이미지 처럼 보여지던 유니온 타입이 Roll을 사용하면 아래와 같이 바뀝니다.
2. FlattendObject
type FlattendObject<T extends object> = SimpleFlattendObject<T> & UnwrappedObject<T>
이 포스팅의 최종 목표가 되는 최상위 레벨의 유틸 타입입니다.
FlattendObject은 SimpleFlattendObject와 UnwrappedObject의 유니온 타입으로 이뤄졌기 때문에 각 타입에 대해 알아보고 다시 돌아와보겠습니다.
2.1 SimpleFlattendObject
type SimpleFlattendObject<T extends object> = {
[K in FilterPrimitiveKeys<T, keyof T>]: T[K]
}
코드를 살펴보면 객체의 키를 FilterPrimitiveKeys 타입으로 필터링 한 뒤 반환하고 있습니다.
필터링의 타겟을 이해하기 위해 FilterPrimitiveKeys의 구현을 살펴보겠습니다.
2.1.1 FilterPrimitiveKeys
type FilterPrimitiveKeys<T, K = keyof T> = K extends keyof T
? T[K] extends null | unknown[]
? K
: T[K] extends object
? never
: K
: never
조건부 타입이 세번 중첩됨에 따라 다소 복잡한 모습입니다.
조건을 하나씩 살펴보면
1. T의 키를 만족하는지?
2. unknown[] 혹은 null을 만족하는지?
3. 객체 타입을 만족하는지?
위 세가지 조건이 중첩되어 있고 최종적으로 object 타입이 할당되지 않은 키를 필터링함을 알 수 있습니다.
다시 말해 중첩되지 않은 필드를 반환합니다. 아래 이미지의 결과를 참고하시길 바랍니다.
FilterPrimitiveKeys가 중첩되지 않은 필드를 필터링하고 있음으로 SimpleFlattendObject는 객체의 필드에서 중첩되지 않은 필드만 필터링해서 반환한다는 것을 알 수 있습니다.
2.2 UnwrappedObject
type UnwrappedObject<T extends object> = ToIntersection<
RecursionHelper<Values<NestedObject<T>>>
>
FlattendObject을 구성하는 두번째 유틸 타입입니다.
눈치빠르신 분들이라면 앞서 2.1에서 SimpleFlattendObject의 기능을 살펴봤고 FlattendObject가 SimpleFlattendObject와 UnwrappedObject으로 구성됨에 되기 때문에 SimpleFlattendObject에 포함되지 않은 요소 즉 중첩된 필드를 필터링해서 평탄화하는 기능임을 유추하셨을 것 같습니다.
SimpleFlattendObject와 마찬가지로 하위 구성을 살펴보며 자세한 구현 방법을 이해해보도록 하겠습니다.
ToIntersection, RecursionHelper, Values, NestedObject 총 네개의 하위 유틸 타입으로 구성되어 있습니다. 각 구성항목을 이해하고 이것들의 조합인 UnwrappedObject 을 정리해겠습니다.
2.2.1 ToIntersection
type ToIntersection<T> = (T extends any ? (_: T) => void : never)
extends (_: infer S) => void
? S
: never
아래와 같은 유니온 타입을 인터렉션 타입으로 변경하는 유틸 타입입니다.
첫 줄을 보면 T를 VoidFunction으로 변환하는 부분이 있습니다. 여기서 T extends any를 이용해 조건부타입을 적용한 이유는 분배법칙을 적용하기 위함입니다. 아래 예제에서는 'A' | 'B'에 분배 적용되어 ((_: "B") => void) | ((_: "A") => void) 이 되었음을 알 수 있습니다.
type ToVoidFunction<T> = T extends any ? (_: T) => void : never;
type T2 = ToVoidFunction<'A' | 'B'>;
/**
* ((_: "B") => void) | ((_: "A") => void)
*/
그 다음 줄에 extends (_: infer S) => void 에서 함수의 매개변수 타입을 추론하게 됩니다.
이 때 인자 타입은 반공변성이기 때문에 유니온 타입 A|B에 대하여 서브타입이면서 가장 넓은 타입을 추론하게 되는데 그 결과 A & B가 되면서 결론적으로 유니온타입이 인터렉션 타입으로 변환되게 됩니다.
(여기서 언급한 함수 인자에 대한 반공변성에 대한 설명은 추후 별도 포스팅을 통해 설명하도록 하겠습니다)
type T1 = ToIntersection<{ a: 'a' } | { b: 'b' }>
/**
* { a: 'a' } & { b: 'b' }
*/
2.2.2 Values
type Values<T extends object> = T[keyof T]
객체의 Values를 유니온 타입으로 반환합니다.
2.2.3 NestedObject
type NestedObject<T extends object> = {
[K in FilterNestedKeys<T, keyof T>]: T[K]
}
SimpleFlattendObject와 유사한 구조를 가지고 있습니다. 객체의 필드를 필터링하는 유틸 타입이기 때문에 필터 조건에 해당하는 FilterNestedKeys의 구현을 통해 어떤 항목을 필터링하는지 알아보겠습니다.
FilterNestedKeys
type FilterNestedKeys<T, K> = K extends keyof T
? T[K] extends null | unknown[]
? never
: T[K] extends object
? K
: never
: never
앞서 설명한 FilterPrimitiveKeys와 정반대의 기능을 한다고 생각하시면 됩니다.
FilterPrimitiveKeys는 중첩되지 않은 키를 필터링하는 기능을 했습니다. 반대로 FilterNestedKeys는 중첩된 키를 반환합니다.
FilterPrimitiveKyes를 확인했으니 NestedObejct의 기능을 확인해보겠습니다.
NestedObejct는 객체에서 1Depth에 중첩 객체 구조를 가지는 필드를 필터링하는 유틸 타입임을 알 수 있습니다.
위 결과를 보면 중첩 객체 구조에서 1Depth 요소를 대상으로 필터링을 하는데 최종적으로는 다중 중첩 구조에서 값이 할당되어 있는 필드를 재귀적으로 탐색해야 합니다. 이 부분에 대한 처리가 어디서 되는지 좀 더 살펴보겠습니다.
2.2.4 RecrsionHelper
type RecursionHelper<T> = T extends object ? FlattendObject<T> : never
조건부 타입으로 저희의 최종 목표인 FlattendObject를 사용하고 있습니다.
다시 UnwrappedOjbect의 구현으로 돌아가보면 중첩된 필드의 Values 유니온 타입에 대해서 자바스크립트의 Array.prototype.map처럼 요소를 순환하며 RecursionHelper가 수행됩니다.
type UnwrappedObject<T extends object> = ToIntersection<
RecursionHelper<Values<NestedObject<T>>>
>
이를 종합해 정리해보면 NestedObject가 1Depth 필드를 대상으로 중첩 객체를 필터링한 결과에 대해서 재귀적으로 중첩 객체 필트를 탐색한다는 것을 정리할 수 있습니다. 이로써 UnwrappedObject를 구성하는 하위 유틸 타입의 조합을 통해 UnwrappedObject의 기능을 정리할 수 있었습니다.
정리
FlattendObject는 SimpleFlattendObject와 UnwrappedObject의 인터섹션 타입으로 이뤄져있고 SimpleFlattendObject은 중첩되지 않은 필드를 필터링하는 역할을 하고 UnwrappedObject은 다중 중첩된 필드에 대해 재귀 탐색을 통해 최종적으로 값이 할당된 필드와 키를 반환하여 다중 중첩 구조를 1depth의 객체 구조로 평탄화하는 유틸임을 이해할 수 있었습니다.
이미 잘 정리된 원문을 재구성함에도 불구하고 이 내용에 대해 설명하는게 수월하지 않았습니다.
이 내용을 이해하기 위해서는 기본기라 할 수 있는 mapped type, intersection type, union type, generic, infer 등에 대한 이해가 필요했으며 이 부분에 대해서는 별도로 언급하지 않았습니다. 아래 참고 섹션에 남긴 원문 시리즈에서는 이 포스팅의 내용을 이해하기 위한 과정이 정리되어 있으니 원문의 시리즈를 읽고 돌아오시면 보다 수월하게 이해되실거라 생각합니다.
참고
https://d2.naver.com/helloworld/7472830
https://d2.naver.com/helloworld/9283310
'typescript' 카테고리의 다른 글
타입스크립트의 공변성과 반변성 (2) | 2024.07.13 |
---|---|
any 타입이 유용한 경우 (1) | 2024.07.13 |
실무자를 위한 현장 밀착 Typescript Best Practice (0) | 2024.06.21 |