Ayden's journal

재귀 타입에서의 추론 컨텍스트 손실

이전에 [ 분배 법칙에서의 타입 추론 컨텍스트 ] 포스트를 통해 나는 타입스크립트에서 분배 법칙이 일어날 때 그 컨텍스트가 유지된다는 것을 확인했고, 이를 기반으로 복잡한 조건부 타입에서도 원하는 타입 추론을 이끌어낼 수 있다는 점을 알게 되었다. 그런데 Caro-Kann 다음 버전의 persist migration을 처리하기 위한 MigrationPipe 타입을 설계하는 중에 한 가지 이상한 현상을 발견하게 되었다.

 

내가 만든 상태 관리 라이브러리 Caro-Kann은 persist 버전 migration을 아래와 같이 처리하고 있다. persist 버전이 몇 개 없다면 괜찮겠지만, 버전이 조금만 늘어나도 정확한 migration을 담보할 수 없는 상황이다. 게다가 이전 버전들이 어떤 구조로 되어있는지 '타입적'으로 제공할 방법이 없다보니 추가적이고 외부적인 문서화를 요구하게 된다.

type Theme = { color: "light" | "dark", ["font-size"]: number };
const strategy = (prevState: any, prevVersion: number) => {
switch (prevVersion) {
case 0:
return { color: prevState, ["font-size"]: 16 };
case 1:
return { color: prevState.color, ["font-size"]: prevState.fontSize };
default:
return prevState;
}
}
const useStore = create<Theme>(
persist(
{ color: "light", ["font-size"]: 16 },
{
local: "theme",
migrate: {
version: 2,
strategy,
},
}
)
);

 

나는 이러한 문제를 해결하기 위해 switch문 대신 함수의 pipeline을 통해 persist migration이 점진적으로 진행되도록 변경하고자 했다. 각 마이그레이션 스텝은 이전 버전의 상태를 입력으로 받아 다음 버전의 상태를 반환하도록 타입이 정의되며, 이러한 함수들이 순차적으로 연결될 때 타입스크립트는 각 단계의 입력과 출력 타입을 정확히 추론할 것으로 기대했다.

이를 위해서는 사용자가 제공하는 함수의 나열이 실제로 이전 버전의 상태를 입력으로 받아 다음 버전의 상태를 반환하는지 검증할 필요가 있다. 때문에 나는 MigrationPipe라고 하는 조금은 복잡한 유틸리티 타입을 통해 타입스크립트 수준에서 이를 검증하고자 했다. 초기에 구성했던 MigrationPipe 타입은 아래와 같다.

type MigrationFn = (props: any) => unknown
type MigrationPipe<T extends Array<MigrationFn>, BeforeFnReturnType = any> =
T extends [infer First, ...infer Rest extends Array<MigrationFn>]
? First extends (props: infer InferPropsType) => infer InferReturnType
? InferPropsType extends BeforeFnReturnType
? [First, ...MigrationPipe<Rest, InferReturnType>]
: never
: never
: []

 

MigrationPipe 타입은 함수들의 배열 T가 주어졌을 때, 각 함수가 올바르게 연결될 수 있는지를 타입스크립트 수준에서 검증하는 역할을 한다. 이 유틸리티 타입은 재귀적으로 동작하며, 배열의 첫 번째 함수 First의 입력 타입이 이전 함수의 반환 타입(BeforeFnReturnType)과 일치하는지를 확인한다.

만약 일치한다면 해당 함수를 결과 배열에 포함시키고, 그 반환 타입을 기준으로 나머지 함수들(Rest)에 대해 다시 같은 검사를 수행한다. 이렇게 해서 모든 함수가 올바르게 체이닝될 수 있다면 전체 파이프라인이 유효하다고 간주되고, 그렇지 않으면 타입 오류가 발생하게 된다. 이를 통해 마이그레이션 함수들이 예상한 순서대로 올바르게 구성되었는지 컴파일 타임에 보장할 수 있다.

 

적어도 처음에 MigrationPipe 타입을 작성할 때는 보장할 수 있을 거라 생각했다.

 

하지만 MigrationPipe 타입을 적용한 새로운 버전의 Caro-kann을 시험해보니, 문제 없이 작동할 것이란 내 예상과 달리 대상에서 0개만 허용한다는 타입 에러가 발생했다.

 

나는 지금까지도 이러한 현상이 발생한 까닭을 명확하게 이해하고 있지 못하다. 다만 몇 가지 테스트를 통해 그 이유를 대강 추론해볼 수 있었다.

MigrationPipe 타입의 목적과는 맞지 않지만, 아래와 같이 [First, ...MigrationPipe<Rest, InferReturnType>]의 자리에 T를 대입하면 모든 문제가 사라진다. 따라서 타입스크립트는 [First, ...MigrationPipe<rest, inferreturntype>]와 T를 동일한 타입으로 보지 않는다는 사실을 알 수 있다.

type MigrationPipe<T extends Array<MigrationFn>, BeforeFnReturnType = any> =
T extends [infer First, ...infer Rest extends Array<MigrationFn>]
? First extends (props: infer InferPropsType) => infer InferReturnType
? InferPropsType extends BeforeFnReturnType
? T
: never
: never
: []

 

이 코드를 읽는 개발자는 0번 인덱스를 제외한 나머지 T가 MigrationPipe로 들어가고, 이것이 반복되어 재귀적으로 호출되다보면 결국 ─ 타입 선언에 문제가 없다면 ─ [First, ...MigrationPipe<rest, inferreturntype>]와 T가 동일할 것이라 예상한다. 하지만 타입스크립트 입장에서는 MigrationPipe가 무엇을 리턴할 지 확신이 없으므로 [First, ...MigrationPipe<rest, inferreturntype>]와 T가 동일하지 않다고 보는 듯하다.

 

타입스크립트가 MigrationPipe의 반환값을 확정하지 못하는 이유는, 재귀적으로 타입을 구성하는 과정에서 조건부 타입과 infer를 사용하는 구조가 복잡해질수록 타입 추론 컨텍스트가 점점 약화되기 때문이다. 특히 [infer First, ...infer Rest]와 같은 패턴 매칭은 배열 구조를 분해하면서 동시에 새로운 타입 컨텍스트를 형성하게 되는데, 이 과정에서 Rest에 대한 정보가 완전히 유지되지 않거나, 타입 연산 도중 확정되지 않은 상태로 남아버리는 경우가 많다.

타입스크립트의 타입 시스템은 완전한 실행 환경이 아닌 정적 분석을 기반으로 하므로, 이런 재귀적 타입 연산 결과가 원래의 배열 T와 동일하다는 사실을 증명하기에는 부족한 추론 능력을 갖고 있다. 결국, 개발자 입장에서는 논리적으로 동일해 보이는 타입이라도, 타입스크립트는 내부 컨텍스트 손실이나 추론 실패로 인해 서로 다르다고 판단하는 것이다.

 

 

이쯤에서 처음에 발생한 타입 에러를 다시 살펴보면, 메시지의 핵심은 "대상에서 0개만 허용된다"는 내용이다. 이는 MigrationPipe가 빈 배열 []로 추론되고 있음을 의미한다. 다시 말해, T extends [infer First, ...infer Rest extends Array]라는 조건이 false로 평가되고 있다는 뜻인데, 이 부분은 직관적으로 쉽게 납득되지 않는다.

 

예를 들어 [First, ...MigrationPipe<Rest, InferReturnType>] 자리에 제네릭 타입 T를 그대로 대입하면, 조건 T extends [infer First, ...infer Rest extends Array]는 true로 평가되어 타입이 정상적으로 추론된다. 하지만 동일한 구조를 가진 [First, ...MigrationPipe<Rest, InferReturnType>]를 그대로 둘 경우, 해당 조건은 false로 평가된다. 이처럼 조건부 타입의 분기가 다시 그 조건식의 판단에 영향을 미치는 모습은, 마치 타입스크립트가 "결과를 먼저 보고 조건을 재평가하는" 것처럼 느껴진다.

이는 타입스크립트가 조건부 타입을 평가할 때, 전체 타입 컨텍스트를 일관되게 유지하지 못하고 중간 추론 과정에서 일부 정보가 단절되거나 축소되는 경우가 있기 때문이다. 특히 재귀적으로 정의된 타입의 결과를 다시 조건식의 일부로 사용하면, 타입스크립트는 해당 타입이 정확히 어떤 구조를 갖는지 확신할 수 없어 조건이 성립하지 않는 것으로 판단할 수 있다.

반면 동일한 구조라도, 그 타입이 외부에서 제네릭으로 명시적으로 전달되면 타입스크립트는 이를 더 적극적으로 해석하며 조건을 true로 평가한다. 결국 타입스크립트의 타입 시스템은 구조적이면서도 컨텍스트에 매우 민감하게 반응하는 특성을 갖고 있어, 예상치 못한 추론 실패가 발생할 수 있다. 이러한 한계를 보완하기 위해서는, 때로는 명시적인 제네릭 매개변수나 조건식 보강을 통해 타입 추론의 방향을 직접 유도해줄 필요가 있다.

 

 

손실된 컨텍스트를 보완하기 위해 아래와 같이 추가적인 조건부 타입을 도입하자, MigrationPipe는 문제 없이 동작하기 시작했다. 핵심은 우리가 재귀적으로 구성한 타입이 실제로 원본 배열 T와 동일한 구조임을 타입스크립트에 명시적으로 알려주는 데 있다. 이를 통해 타입스크립트는 중간 추론 단계에서 놓칠 수 있는 정보를 보다 명확하게 인식할 수 있게 되었고, 그 결과 재귀 타입 연산 과정에서도 컨텍스트가 안정적으로 유지된다. 이러한 조건의 추가는 타입스크립트의 추론 한계를 보완해 주며, 결국 MigrationPipe는 우리가 기대한 대로 타입 안전성을 확보한 채 정확하게 동작하게 된다.

type MigrationPipe<T extends Array<MigrationFn>, BeforeFnReturnType = any> =
T extends [infer First, ...infer Rest extends Array<MigrationFn>]
? First extends (props: infer InferPropsType) => infer InferReturnType
? InferPropsType extends BeforeFnReturnType
? [First, ...MigrationPipe<Rest, InferReturnType>] extends T
? [First, ...MigrationPipe<Rest, InferReturnType>]
: never
: never
: never
: []

 

첫 번째 예시 코드의 strategy 프로퍼티를 살펴보면 함수의 리턴 타입이 다음 함수의 파라미터 타입과 일치해 아무런 문제가 발생하지 않고 있다. 그러나 다음 예시 코드와 같이 함수의 리턴 타입이 다음 함수의 파라미터 타입과 일치하지 않으면 MigrationPipe가 never로 추론되어 타입 에러가 발생하게 된다.

 

 

 

결론적으로, 타입스크립트의 타입 시스템은 강력하면서도 복잡한 추론 메커니즘을 갖고 있지만, 그 추론은 항상 직관적으로 동작하지는 않는다. 특히 조건부 타입과 재귀적 타입을 함께 사용할 때, 타입 추론 컨텍스트가 일부 손실되거나 축소되면서 의도한 대로 타입이 평가되지 않는 경우가 발생한다. 이러한 상황에서는 타입스크립트가 타입의 구조를 "알고 있어도 확신하지 못하는" 듯한 모순된 판단을 하게 되고, 이는 개발자 입장에서 매우 혼란스러울 수 있다.

따라서 복잡한 타입 로직을 구성할 때는, 제네릭 파라미터나 중간 타입을 명시적으로 도입해 타입스크립트의 추론 흐름을 보조해주는 것이 중요하다. 결국 타입 안정성을 확보하기 위해서는, 때로는 타입 시스템의 동작 방식을 깊이 이해하고 그 한계를 우회하는 설계가 필요하다.

 

블로그의 프로필 사진

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기