
infer, never만 보면 두려워지는 당신을 위한 타입 추론 - 응용 문제
infer, never만 보면 두려워지는 당신을 위한 타입 추론 - 응용 문제 관련
'infer, never만 보면 두려워지는 당신을 위한 타입 추론' 관련 영상은 링크에서 보실 수 있습니다.
앞에서 배운 내용을 토대로 어려운 타입 문제를 하나 풀어 보겠습니다.
자, 여기 예쁜 재귀 함수가 있습니다.
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
}
함수 구현부는 신경쓰지 마시고, 동작만 한 번 볼까요?
flattenObject({
x: 0,
y: 'babo',
z: ['hi'],
a: {
b: {
c: null,
},
d: undefined,
}
})
//
// { x: 0, y: 'babo', z: ['hi'], c: null, d: undefined }
네, 그렇습니다. 이 함수는 중첩된 객체를 평탄하게 펴는 함수입니다. 이 함수에 타입을 달아봅시다. 단, 아래의 전제 조건이 붙습니다.
- 모든 속성의 이름은 유일성을 만족한다(=겹치지 않는다).
- 배열은 펼치지 않는다.
null이나undefined값은 그대로 유지되어야 한다.
평탄한 객체와 중첩된 객체 분리하기
제일 먼저 생각나는 접근은 '중첩되는 속성'과 '중첩되지 않는 속성'을 쪼개서 처리한 뒤, 조립하는 것입니다.
먼저, 중첩되지 않은 속성부터 추출해 봅시다. T에 undefined나 never, unknown 같은 이상한 타입을 고려하지 않기 위해 extends object로 방어합니다. 그 뒤 지우고 싶은 값인 경우에 never로 처리해 버리면 되지 않을까요?
type SimpleFlattendObject<T extends object> = {
[K in keyof T]: FilterValueType<T[K]>
}
type FilterValueType<T> = T extends object
? T extends null | unknown[]
? T
: never
: T
한 번 테스트해 볼까요?
const test: SimpleFlattendObject<{
x: number,
y: {
z: []
}
}> = {
x: 0,
}
// Property 'y' is missing in type '{ x: number; }' but required in type `FlattenKeys<{ ... }>`
의도한 대로 동작하지 않습니다. 객체 리터럴에서 자동 완성을 확인해 보니, y가 never로 간주됩니다. y가 test 객체에 없어야 되는 건 맞지만, 값이 never 타입이라고 해서 키까지 자동으로 지우진 않습니다. 따라서, 값이 아닌 속성 키를 원천적으로 지워야 합니다.
type SimpleFlattendObject<T extends {}> = {
[K in FilterPrimitiveKeys<T, keyof T>]: T[K]
}
type FilterPrimitiveKeys<T, K> = K extends keyof T
// 모든 배열을 허용하기 위해 가장 큰 타입인 unknown 할당
? T[K] extends unknown[]
// 조건을 만족했을 때 키를 반환. 일종의 early return
? K
: T[K] extends object
? never
: K
: never
테스트를 해 볼까요?
type X = SimpleFlattendObject<{
x: number,
y: {
z: string,
},
a: null,
b: [1],
}>
//
// {
// x: number,
// a: null,
// b: [1]
// }
이제 잘 되네요! 이제 중첩된 객체만 잘 처리하면 되겠군요. 중첩되지 않은 값을 처리한 코드를 완전히 반전시키면 되겠네요.
type NestedObject<T extends object> = {
[K in FilterNestedKeys<T, keyof T>]: T[K]
}
type FilterNestedKeys<T, K> = K extends keyof T
? T[K] extends unknown[]
? never // 아까랑 정반대!
: T[K] extends object
? K
: never
: never
테스트해 볼까요?
// 예시
type X = NestedObject<{
x: number
y: { z: number }
a: { b: { c: [] }}
d: undefined
}>
//
// {
// y: { z: number }
// a: { b: { c: [] }}
// }
좋습니다.
중첩된 객체 한 단계 들어올리기
이제 y 안에 들어있는 z, a 안에 들어있는 b를 한 단계 올리고 싶습니다. 일단 재귀적인 건 생각하지 말고, 한 단계만 올려봅시다.
우선 저 안에 있는 값의 '키'는 더 이상 중요하지 않습니다. 그러니 값의 타입만 모조리 추출합시다.
type Values<T extends object> = T[keyof T]
type X = Values<{ a: string, b: number }> // string | number
아까 만들었던 NestedObject를 여기에 넣어봅시다.
type UnwrappedObject<T extends object> = Values<NestedObject<T>>
테스트해 봅시다.
type X = UnwrappedObject<{
x: number
y: { z: number }
a: { b: { c: [] }}
d: undefined
}>
//
// {
// z: number
// } | {
// b: { c: [] }
// }
의도한 것과 비슷해졌습니다. |를 &로 바꾸기만 하면 원하는 타입이 될 것 같네요. 그런데 |를 어떻게 &로 바꾸죠?
합집합을 교집합으로
Stack Overflow에 있던 한 재야의 고수는 아래와 같은 핵을 제시했습니다(출처: Transform union type to intersection type).
type ToIntersection<T> = (
T extends any
? (_: T) => void
: never
) extends (_: infer S) => void
? S
: never
생긴 게 상당히 당황스럽지만, 차근차근 하나씩 분석해 봅시다. 먼저 첫 번째 괄호를 임시로 F<T>라고 합시다.
F<T> = T extends any ? (_: T) => void : never
이 타입 표현식은 T를 (_: T) => void라는 함수 타입으로 바꿉니다. 이때 분배 법칙을 적용하기 위해서 T extends any를 써 준 것입니다. 즉, 아래와 같은 일이 벌어집니다.
type X = F<A | B>
// F = ((x: A) => void) | ((x: B) => void)
이제 나머지 바깥의 조건부 타입을 해석하면 됩니다. 어떤 함수의 나열이 있고, 그 함수를 모두 포괄하는 함수와 인자를 infer로 추론하네요!
type ToIntersection<T> = (...) extends (_: infer S) => void ? S : never
그런데 함수의 인자 타입은 반공변성 때문에 방향이 거꾸로라고 했죠? 따라서 인자의 타입은 더 작아져야 합니다. A의 서브타입이면서 동시에 B의 서브타입인, 가장 넓은 타입이 필요합니다. 집합론에 따르면 이를 만족하는 타입은 A & B입니다.
옆길로 좀 샜지만 이걸 UnwrappedObject에 적용해 봅시다.
type UnwrappedObject<T extends object> = ToIntersection<Values<NestedObject<T>>>
type X = UnwrappedObject<{
x: number
y: { z: number }
a: { b: { c: [] }}
d: undefined
}>
/*
{
z: number
} & {
b: { c: [] }
}
*/
의도한 대로 동작하네요! 그러면 아까 단순하게 풀어헤친 걸 같이 합쳐주면, 중첩된 객체 한 단계 들어올리기는 끝납니다.
type FlattendObject<T extends object> = SimpleFlattendObject<T> & UnwrappedObject<T>
지연 평가를 활용하여 재귀적으로 수행하기
이제 마지막 난관이 남았습니다. 바로 추가적인 중첩에 대해서 재귀적으로 수행하는 것입니다.
다중 중첩된 객체는 NestedObject의 값 안에 들어있습니다. 우리가 만든 제네릭인 FlattendObject는 한 단계에 대해서 이를 수행했으니 이걸 집어넣으면 되지 않을까요? Values로 풀어헤친 다음, 교집합으로 변환하기 전에 재귀적으로 수행하면 되겠군요!
type UnwrappedObject<T extends object> = ToIntersection<FlattendObject<Values<NestedObject<T>>>>
안타깝게도 이렇게 하면, FlattendObject의 정의에 FlattendObject가 사용되어 무한 루프가 됩니다. 그러나 조건부 타입의 지연 평가 성질을 이용하면 이를 타개할 수 있습니다.
type RecursionHelper<T> = T extends object ? FlattendObject<T> : never
type UnwrappedObject<T extends object> = ToIntersection<RecursionHelper<Values<NestedObject<T>>>>
사실 T가 객체가 아닌지 검사를 안 해도 되는데, 이미 Values가 객체만 반환하기 때문입니다. 그런데 tsc는 보수적으로 타입을 추론한다고 했죠? 즉, Values가 무조건 객체라는 것을 보증하지 못합니다. 그러므로 extends object로 명시적으로 방어를 한 것입니다.
자, 이제 테스트를 해봅시다. 그런데 타입 추론의 깊이가 너무 깊다 보니, IDE가 제대로 타입을 보여주지 않습니다. 하지만 Matt Pocock (mattpocockuk)이 고안한 Roll이라는 핵을 쓰면, 못생긴 타입을 펼쳐서 보여준다고 합니다. 아마 tsc의 힌터 내부 구현을 고려한 것 같습니다.
type Roll<T> = {
[K in keyof T]: T[K]
} & {}
type X = Roll<FlattendObject<{
a: 'a'
b: null
c: {
d: 'd',
e: {
f: 0
},
g: null
}
}>>
/*
type X = {
a: "a";
b: null;
d: "d";
g: null;
f: 0;
}
*/
이제 이 핵을 함수에 추가하면 됩니다. result는 중간 과정을 위해서 쓰이는 구현에 관련된 값이므로 굳이 정밀하게 추론할 필요는 없습니다.
function flattenObject<T extends object>(obj: T, result: any = {}): FlattendObject<T>
전체 코드는 TypeScript: TS Playground에서 확인할 수 있습니다.
마치며
굳이 이렇게까지 정밀한 타입 추론을 사용해야만 하는가 하는 의문을 느낄 수 있습니다. 저도 어느 정도는 동의합니다. 고급 타입 추론은 진입 장벽이 높기 때문에, 모든 팀원이 TS에 통달하지 않았다면 오히려 협업을 방해할 수도 있겠습니다. 실무에서 푸는 대부분의 문제는 이렇게 복잡하지도 않고요. 하지만 꼭 대다수의 실무에 직접적인 도움을 주지 않는다고 해서 쓸모없는 지식은 아닙니다. 웹 개발자가 시스템 개발을 하지 않지만 메모리 구조에 대한 이해가 필요하듯이요.
오픈소스 라이브러리를 개발하는 개발자로서 타입에 대한 전문성은 필수불가결합니다. 실제로 저는 라이브러리 개발 도중, 위에서 풀어본 문제를 능가하는 난이도의 타입을 다룬 적도 있습니다. 이 모든 것은 지적인 현학성을 추구하기 위함도, 아무도 읽지 못하는 무기를 개발하기 위함도 아닙니다. 그저 라이브러리의 사용성을 증대시키고 더 나은 도구를 만들기 위함입니다.
infer, never만 보면 두려워지는 당신을 위한 타입 추론 시리즈