Ayden's journal

분배 법칙에서의 타입 추론 컨텍스트

유니온 타입의 분배 법칙

TypeScript에서 제네릭 타입의 자리에 유니온 타입이 오게 되면, 타입 연산 ─ 주로 조건부 타입, 맵드 타입, 템플릿 리터럴 타입, 스프레드 연산 등 ─ 과 결합될 때 분배 법칙(distributive law)이 적용된다. 이는 제네릭 타입이 유니온 타입을 받을 경우, 개별 요소에 대해 조건부 타입이 각각 평가된다는 의미이다. 예를 들어, 다음과 같은 조건부 타입을 고려해 보자. 위 코드에서 T에 "hello" | 42가 들어가면, 단순히 string과 비교하는 것이 아니라 각 요소가 개별적으로 평가된다. 이를 naked 타입이라고도 부른다.

type Example<T> = T extends string ? number : boolean;

type Result = Example<"hello" | 42>;
// type Result = 
//   | ("hello" extends string ? number : boolean)
//   | (42 extends string ? number : boolean)
//
// type Result =
//   | number
//   | boolean

 

이를 방지하기 위해 흔히 Wrapped 타입이라 부르는 기법을 사용할 수 있다. 이는 T를 [T], { value: T } 등의 형태로 감싸서 조건부 타입의 분배를 막는 방식이다. 아래와 같이 제네릭 타입 T를 배열로 감싸 정의하면, TypeScript는 T를 하나의 단일 타입으로 간주하여 평가한다. 즉, "hello" | 42가 개별적으로 분리되지 않고 전체가 한 번에 비교되므로 결과가 boolean이 된다. 이처럼 Wrapped 타입을 활용하면 유니온 타입의 원치 않는 분배를 방지하고, 보다 예측 가능한 타입 연산을 수행할 수 있다.

type Example<T> = [T] extends [string] ? number : boolean;

type Result = Example<"hello" | 42>;
// type Result = ["hello" | 42] extends [string] ? number : boolean
// type Result = boolean

 

 

IsUnion과 타입 추론 컨텍스트

이번주 내내 나는 type-challenges라고 불리는 TypeScript 문제들을 풀고 있었다. TypeScript 공부를 게을리 하지 않았다고 생각했음에도 불구하고 '쉬움'은 쉽지 않았고, '보통'도 보통이 아니었다. 1097번 문제인 IsUnion은 그 중에서도 압권이었는데, 이틀을 고민했음에도 어떤 식으로 풀어야할지 감도 오지 않았다. 결국 다른 사람들의 풀이를 참고할 수 밖에 없었는데, 다양한 풀이 중에서 내 눈길을 끈 것은 아래의 구현이었다.

type IsUnion<T, K = T> =
  [T] extends [never]
    ? false
    : T extends K
      ? [K] extends [T]
        ? false
        : true
      : never

이 코드의 핵심은 T가 유니온 타입인지 확인하는 방식으로, 유니온 타입 내에서 각 타입이 다른지 비교하는 점에서 중요한 역할을 한다. 이 문제는 조건부 타입과 분배 법칙이 복잡하게 얽혀 있어, 제대로 이해하려면 타입의 흐름을 면밀히 분석해야 했다.

 

[T] extends [never]

첫 조건부 타입인 [T] extends [never]는 제네릭 타입 T가 never인지 아닌지를 판단한다. 그런데 naked 타입이 아니라 wrapped 타입을 사용해서 조건을 처리하는 이유는 무엇일까? 아래의 예시 코드를 보자. 제네릭 타입에 never 타입을 넣었으니 리턴되는 타입은 true일 것 같지만, 실제로는 never가 리턴된다.

type IsNever<T> = T extends never ? true : false

type Res = IsNever<never> // never

타입스크립트의 리드 엔지니어인 Ryan Cavanaugh는 이러한 현상에 대해 1) 타입스크립트는 조건부 타입에 대해 자동적으로 유니온 타입을 할당하는데 2) never 타입은 빈 유니온 타입이기 때문에 3) 할당할 것이 없으므로 조건부 타입은 그 전체가 never로 평가된다고 설명했다. 따라서 이를 방지하는 유일한 해결책은 암묵적 할당을 막고 타입 매개 변수를 wrapped 타입으로 처리하는 것이다.

 

타입 추론 컨텍스트

제네릭 타입의 자리에 never 외의 타입이 들어온다면, IsUnion 타입의 구현은 아래와 같을 것이다. K = T 이므로 T extends K는 언제나 참이다. 그런데 중요한 것은 그 이후이다.

type IsUnion<T, K = T> =
  T extends K
    ? [K] extends [T] ? false : true
    : never

 

T extends K가 항상 참이기 때문에 나는 IsUnion의 구현을 아래와 같이 극단적으로 간소화 시켜버렸다. 그런데 타입을 이렇게 바꾸자 IsUnion은 false만을 리턴하기 시작했다. 항상 참인 조건을 제거한 것 뿐인데 동작이 달라진다는 사실을 나는 쉽게 이해할 수 없었다. 한참을 찾아보고 물어본 끝에 나는 그 원인을 알아낼 수 있었다.

 

type IsUnion<T, K = T> = [K] extends [T] ? false : true

 

처음에 나는 T extends K의 추론 결과와 무관하게 [K] extends [T]의 추론이 진행된다고 생각했었다. 따라서 IsUnion 타입이 아래와 같은 흐름으로 추론될 것이라 추측했었다. 하지만 실제로는 이렇지 않았고, 분배 법칙이 적용되는 동안에는 이후 조건부 타입의 추론에 대해 컨텍스트가 유지된다는 사실을 알게 되었다.

type Test = IsUnion<"a" | "b">

// 언제나 참인 조건 처리
type Test = 
  | ("a" extends "a" | "b" ? [K] extends [T] ? false : true : never)
  | ("b" extends "a" | "b" ? [K] extends [T] ? false : true : never)

// wrapped 타입 처리
type Test = 
  | (["a" | "b"] extends ["a" | "b"] ? false : true)
  | (["a" | "b"] extends ["a" | "b"] ? false : true)

// 조건 해결
type Test = 
  | false
  | false

// 추론 끝
type Test = false

 

분배 법칙에서의 타입 추론 컨텍스트는 아래와 같이 진행된다. 유니온 타입이 분배됨에 따라 해당 컨텍스트가 추론의 끝까지 유지되는 것이다. 즉, 유니온 타입의 각 요소는 조건부 타입 내에서 개별적으로 처리되어, 각 요소에 대해 타입 추론이 독립적으로 이루어진다. 이 과정에서 조건부 타입이 적용된 후, 각 요소에 대해 참과 거짓을 평가하며, 최종적으로 모든 조건이 해결되어 결합된 타입이 추론된다. 이를 통해 유니온 타입의 분배가 어떻게 작동하는지, 그리고 각 조건부 타입의 결과가 어떻게 도출되는지를 명확하게 알 수 있다.

type Test = IsUnion<"a" | "b">

// 언제나 참인 조건 처리
type Test = 
  | ("a" extends "a" | "b" ? ["a" | "b"] extends ["a"] ? false : true : never)
  | ("b" extends "a" | "b" ? ["a" | "b"] extends ["b"] ? false : true : never)

// wrapped 타입 처리
type Test = 
  | (["a" | "b"] extends ["a"] ? false : true)
  | (["a" | "b"] extends ["b"] ? false : true)

// 조건 해결
type Test = 
  | true
  | true

// 추론 끝
type Test = true

 

 

RemoveIndexSignaturer와 타입 추론 컨텍스트

한 가지 예시를 더 들어보자. RemoveIndexSignaturer는 객체 타입에서 인덱스 시그니처를 제외한 나머지 프로퍼티로 이루어진 객체 타입을 리턴하는 유틸리티 타입이다. PropertyKey는 string | number | symbol 로 이루어진 유니온 타입이며, 직접 해당 타입을 사용하는 대신 분배 법칙이 일어날 수 있도록 제네릭 타입에 할당해주었다.

type RemoveIndexSignature<T extends object, P=PropertyKey> = {
  [K in keyof T as P extends K ? never : K extends P ? K : never]: T[K]
}

 

아래와 같은 타입 Bar가 있다고 해보자. 이를 RemoveIndexSignaturer 타입을 사용하여 인덱스 시그니처를 제거하려고 할 때, 실제로 타입 추론 과정이 어떻게 진행되는지 따라가보자.

type Bar = {
  [key: number]: any
  bar(): void
  0: string
}

type Test = RemoveIndexSignature<Bar>
// 1. Bar의 프로퍼티 키에 대한 분배 법칙 적용
type Test = {  
  [number as P extends number ? never : number extends P ? number : never]: T[number]
  ["bar" as P extends "bar" ? never : "bar" extends P ? "bar" : never]: T["bar"]
  [0 as P extends 0 ? never : 0 extends P ? 0 : never]: T[0]
}

// 2. P에 대한 분배 법칙 적용
type Test = {  
  [number as string extends number ? never : number extends string ? number : never]: T[number]
  [number as number extends number ? never : number extends number ? number : never]: T[number]
  [number as symbol extends number ? never : number extends symbol ? number : never]: T[number]
  ["bar" as string extends "bar" ? never : "bar" extends string ? "bar" : never]: T["bar"]
  ["bar" as number extends "bar" ? never : "bar" extends number ? "bar" : never]: T["bar"]
  ["bar" as symbol extends "bar" ? never : "bar" extends symbol ? "bar" : never]: T["bar"]
  [0 as string extends 0 ? never : 0 extends string ? 0 : never]: T[0]
  [0 as number extends 0 ? never : 0 extends number ? 0 : never]: T[0]
  [0 as symbol extends 0 ? never : 0 extends symbol ? 0 : never]: T[0]
}

// 3. 첫 번째 조건부 로직 처리
type Test = {  
  [number as number extends string ? number : never]: T[number]
  [number as never extends number ? number : never]: T[number]
  [number as number extends symbol ? number : never]: T[number]
  ["bar" as "bar" extends string ? "bar" : never]: T["bar"]
  ["bar" as "bar" extends number ? "bar" : never]: T["bar"]
  ["bar" as "bar" extends symbol ? "bar" : never]: T["bar"]
  [0 as 0 extends string ? 0 : never]: T[0]
  [0 as 0 extends number ? 0 : never]: T[0]
  [0 as 0 extends symbol ? 0 : never]: T[0]
}

// 4. 두 번째 조건부 로직 처리
type Test = {  
  [number as never]: T[number]
  [number as never]: T[number]
  [number as never]: T[number]
  ["bar" as "bar"]: T["bar"]
  ["bar" as never]: T["bar"]
  ["bar" as never]: T["bar"]
  [0 as never]: T[0]
  [0 as 0]: T[0]
  [0 as never]: T[0]
}

// 5. 유니언 병합
type Test = {  
  [number as never | never | never]: T[number]
  ["bar" as "bar" | never | never]: T["bar"]
  [0 as never | 0 | never]: T[0]
}

type Test = {  
  [number as never]: T[number]
  ["bar" as "bar"]: T["bar"]
  [0 as 0]: T[0]
}

type Test = {  
  [(number | "bar" | 0) as (never | "bar" | 0)]: T[number | "bar" | 0]
}

type Test = {  
  [(number | "bar" | 0) as ("bar" | 0)]: T[number | "bar" | 0]
}

// 6. as 처리
type Test = {  
  [("bar" | 0)]: T["bar" | 0]
}

 

 


분배 법칙에서 타입 추론에 대한 컨텍스트가 유지된다는 사실은 오늘 처음 알았는데, 이 전체 과정을 살펴보면서 타입 시스템을 더 깊이 이해하게 된 것 같다. 유니온 타입이 조건부 타입 내에서 어떻게 분배되고, 그 결과가 어떻게 추론되는지 알게 되어 훨씬 더 유연하고 정확하게 타입을 다룰 수 있을 것 같다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기