
infer, never만 보면 두려워지는 당신을 위한 타입 추론 - 기초 타입 이론
infer, never만 보면 두려워지는 당신을 위한 타입 추론 - 기초 타입 이론 관련
'infer, never만 보면 두려워지는 당신을 위한 타입 추론' 관련 영상은 링크에서 보실 수 있습니다.
TypeScript(이하 TS)는 JavaScript(이하 JS)의 런타임 취약성을 극복하고자, 타입 이론을 도입하여 정적 분석을 실현한 언어입니다. 하지만 대다수의 웹 애플리케이션 개발 과정에선 TS의 기능을 깊게 사용할 일은 드뭅니다. 기껏해야 인터페이스를 선언하거나, Record 등의 유틸리티 타입 일부를 쓰는 정도입니다.
그러나 라이브러리를 개발한다면 이야기는 달라집니다. 사실상 TS가 필수가 된 시대상 속에, 심지어 TS를 사용하지 않는 사람조차 IDE의 타입 추론 기능 수혜를 받고 있을 정도로, 타입은 빼놓을 수 없는 관심사가 되었습니다. 타입 추론이 제대로 이루어지지 않으면 라이브러리의 사용성은 현저히 떨어집니다.
제대로 된 타입 추론을 실현하려면, 수학적으로 타입 이론을 이해하고 적용할 수 있어야 합니다. 이 글의 시리즈는 TS를 관통하는 타입 추론의 원리를 기초 타입 이론과 고급 타입 추론으로 나누어 살펴보고, 라이브러리 개발자가 경험할 법한 실전 문제를 풀어봅니다. 단번에 읽기엔 벅찰 수 있습니다. 여유가 있다면 천천히 음미하시면서 학습하시는 것을 권장합니다.
이 글을 읽으면 좋은 분
- JS 라이브러리 개발자
- 타입이 추론되는 원리가 궁금한 개발자
- infer 등으로 고급 제네릭을 사용해 보고 싶은 개발자
타입 이론의 역사적 배경
타입 이론의 근간에는 수학기초론(foundation of mathematics)이 있습니다. 20세기 이전까지만 하더라도 수학은 생각보다 엄밀하지 않았는데요, 1908년 러셀의 역설이 발견되며 모든 수학의 기초인 집합이 흔들립니다. 이 문제를 해결하고자 ZFC 공리계, 괴델의 불완전성 정리 등의 엄밀한 고찰이 계속되었습니다.

타입 이론은 위대한 과학자 중 하나인 앨런조 처치(Alanzo Church)가 람다 대수(lambda calculus)를 고찰하면서 탄생했습니다. 처치 외에도 많은 수학자가 유사한 시도를 했고, 비교적 최근까지도 학계에서 다뤄지는 주제라고 합니다. 수리철학적으로 더 자세한 내용을 알고 싶다면 스탠포드 대학의 자료를 참고해 주세요.
TypeScript와 타입 이론
TS 1.8까지는 공식 문서가 존재했으나, 이후 Microsoft가 문서 유지보수를 포기했습니다. 때문에 현재 TS의 사양은 TypeScript 컴파일러(이하 tsc)의 구현 그 자체가 된 상황입니다. 게다가 구버전 문서에도 일관성 있는 타입 추론 알고리즘에 대한 정보는 찾아보기가 어렵습니다.
따라서 이 글에서는 tsc를 블랙박스로 간주하고, 수학적 일관성과 실험을 근거로 얻은 내용을 서술합니다. 만약 이 글의 내용과 충돌하는 사항이 있다면, TS 저장소의 Discussion에서 논의하는 것을 권장합니다. 시간이 지나 버전이 바뀌면 동작이 변경될 수 있습니다. 또한 이 글에서는 실용적인 개발을 돕기 위해, 엄밀함을 다소 포기하고 간결한 설명을 목표로 했음을 알립니다.
타입이란
타입은 다음과 같이 정의할 수 있습니다.
어떤 심벌(symbol, ≒변수명)에 엮인(binded) 메모리 공간에 존재할 수 있는 값(value)의 집합과 그 값들이 가질 수 있는 성질(properties)
구체적인 예시를 통해 알아봅시다. 아래는 값 3.141592가 타입 number에 속한다는 것을 표현한 것입니다.
3.141592 : number
- number 타입으로 엮인 메모리 공간에는
'foo',null같은 값은 올 수 없습니다. - number 타입은 덧셈, 곱셈 등의 산술 연산을 할 수 있으며,
toString,toFixed등의 속성이 있습니다.
타입과 타입 간에도 관계가 존재하는데요, 타입 A가 다음을 만족할 때 타입 B의 서브타입이라고 합니다.
타입 B의 모든 속성이 A에도 있을 것
속성이 무엇이냐, primitive 타입일 땐 어떻게 비교할 것이냐 하는 것은 뒤에서 살펴보고 지금은 거시적인 개념만 익혀두겠습니다. 아래는 타입 { x: number; y?: string } 이 { x: number }의 서브타입임을 표현한 것입니다.
{ x: number; y?: string } ≲ { x: number }
타입은 부분순서집합
앞서 살펴보았듯, 타입은 비교 가능합니다. 비교 가능한 집합 중 가장 익숙한 것으로 실수(real number)가 있는데요. 타입의 대소 관계와 실수의 대소 관계는 약간 다릅니다.
모든 임의의 두 실수는 다음의 둘 중 최소한 하나를 만족합니다.
특별히 a ≥ b이면서 a ≤ b인 관계를 a = b라고 합니다. 이런 집합을 전순서집합(totally ordered set)이라고 합니다. 엄밀하게는 몇 가지 조건이 붙지만, 지금은 중요하지 않으니 참고만 해주세요.
타입은 조금 다릅니다. 다음 4가지 중 하나만 만족합니다.
- 이면서
특별히 a ≳ b 이면서 a ≲ b 인 경우를 a ≃ b 라고 표현하겠습니다. 여기서 중요한 점은 a ≳ b가 아니라고 해서 a ≲ b라고 할 수 없다는 점입니다. 이런 집합을 부분순서집합(partially ordered set)이라고 합니다.
구체적인 예시를 보고 기호를 체화해 보세요.
number ≳ 42
symbol | string ≲ number | symbol | string
{ x?: number } ≃ { x: number | undefined }
number ≄ { x: number }
타입과 값의 대입
뜬금없이 대소 비교를 왜 배웠느냐, 바로 값을 대입하는 조건을 정의하기 위해서입니다. TS는 안전한 대입과 참조를 실현하는 방법으로서 타입 이론을 사용합니다.
는 올바른 대입
다음 코드를 하나씩 볼까요?
const x: number = 42
// number ≳ number이므로, 대입 가능
const x: string = 42
// string ≄ number이므로, 대입 불가능
const x: string | number = 42
// string | number ≳ number이므로, 대입 가능
이렇게 동작하는 이유는, 서브타입은 반드시 슈퍼타입이 가지는 성질을 갖기 때문입니다. 즉, 속성에 대한 안전한 참조를 할 수 있습니다. null이나 undefined를 대상으로 .toString() 등의 참조를 하면 런타임 오류가 발생하죠? 타입 이론은 이를 null 타입이 toString이라는 속성을 가지지 않았기 때문으로 본 것입니다.
타입의 종류에 따른 대소 비교
앞에서는 간단하게 소개했지만, 이제 정식으로 타입의 대소 비교를 어떻게 하는지 알아봅시다.
원시 타입(primitive type)
원시 타입이란 다음 6가지를 말합니다.
booleannumberstringsymbolnullundefined
이들은 공리적으로 정의합니다. null을 제외하면, JS에 존재할 수 있는 값에 typeof를 수행했을 때 결과가 저 중 하나라면 그것이 곧 자신의 타입 이름입니다. 예를 들어 typeof 3.141592 === 'number'이면 해당 리터럴은 number 타입입니다.
이들은 자기 자신과는 서브타입 관계이고, 다른 타입과는 무관계입니다.
참고로 TS에서는 null을 객체(object)의 서브타입으로 간주하지 않습니다. 왜냐하면 null은 참조할 수 있는 속성이 하나도 없기 때문입니다. JS에서 역사적인 이유로 인해 typeof null === 'object'인 것과 대조적입니다.
리터럴 타입(literal type)
리터럴 타입이란 어떤 슈퍼타입에 속한 값 '1개'만으로 구성된 타입입니다. 예를 들어 number ≳ 6과 같은 경우가 있습니다.
참고로 대부분의 경우, 리터럴을 쓰면 해당 심벌은 원시 타입으로 간주됩니다. 강제로 리터럴 타입으로 변환할 필요가 있다면 as const 키워드를 붙이면 됩니다.
객체 타입(object type)
객체 타입은 개별 속성의 방향이 일치할 때, 전체의 대소 방향도 똑같이 따라갑니다.
말이 어려우니 구체적인 예시를 보겠습니다. 여기 2가지 타입이 있습니다.
type A = {
x: number
y?: string
z: boolean
}
type B = {
x: number
z: false
a: 'foo'
}
tsc는 A ≳ B인지 궁금해 합니다. 이때 우리는 타입의 정의에서 잠시 보았던 '슈퍼타입의 모든 속성'을 따지기 시작합니다. A가 슈퍼타입인지 물어봤으니, A의 속성을 나열해야겠지요?
A는 x, y, z라는 속성을 가지고 있으며, 각각 number, string | undefined, boolean을 타입으로 갖습니다. 이제 각 속성 이름에 대해, B에서 해당 속성이 무슨 타입인지 확인합니다. 이를 간결하게 나타내면 다음과 같습니다.
A['x'] = number ≳ number = B['x']
A['y'] = string | undefined ≳ undefined = B['y']
A['z'] = boolean ≳ false = B['z']
B['a']는 왜 비교하지 않냐고요? 슈퍼타입인 A에는 a라는 속성이 없기 때문입니다. B 타입의 값은 A 타입에 대입할 수 있고, A 타입을 통해선 a 속성에 접근하지 못합니다. 따라서 a의 타입은 중요하지 않습니다.
이렇게 어떤 더 작은 관심사에서의 방향이 거시적인 대소 관계 방향과 일치할 때 그 성질을 공변성(covariance)이라고 합니다. 객체 타입의 대소 관계는 각 타입이 가진 성질에 대하여 공변적입니다. 만약 하나라도 방향이 일치하지 않을 경우, 두 타입은 무관계입니다.
배열/튜플 타입
배열도 객체입니다. 튜플은 배열의 일종이죠. 따라서 이들은 객체와 동일한 원리가 적용됩니다.
배열의 타입은 개별 원소 타입에 대하여 공변적입니다.
객체와 배열의 차이가 있다면, number를 키 값으로 갖는다는 점입니다. 다만 keyof string[]이 number를 직접 반환하지는 않는데, 배열에는 concat과 같은 다른 속성도 많이 있기 때문입니다. 하지만 명백하게 keyof string[] ≳ number는 맞습니다.
튜플 타입은 length가 number의 리터럴 타입이라는 점이 배열과 다릅니다. 만약 length 범위 밖의 인덱스를 참조 시, 타입 오류를 발생시키며 해당 참조값은 any로 추론합니다.
키 타입(keyof)
키 타입이란 객체 타입의 속성 이름의 합집합(|)으로 이루어진 타입입니다.
keyof { x: number; y?: string; z: boolean }
≃ 'x' | 'y' | 'z'
모든 키 타입은 number | string | symbol의 서브타입입니다.
함수 타입
함수 타입은 반환형과 인자의 타입 두 가지 요소가 조합된 것이며, 호출 가능합니다. 함수 타입의 포함 관계는 다소 복잡한데, 반환형에는 공변적이고 인자형에는 반변적(contravariant)입니다.
반환형에 공변적
두 함수의 인자를 고려하지 않을 때, 반환형의 포함 관계의 방향이 곧 전체 함수 타입의 포함 관계를 결정합니다.
왜 함수 타입은 반환형에 공변적일까요? 바로 반환값이 rvalue로 쓰이기 때문입니다. 앞에서 특정 타입의 lvalue에는 그 서브타입의 rvalue를 넣을 수 있다고 했습니다. 공변적이라면 더 작은 타입의 함수를 큰 타입에 대입하더라도 사용할 때 일관성을 유지할 수 있습니다.
const fa: () => A = ...
const fb: () => B = ...
let a: A
let f: () => A
// () => A ≳ () => B라면, fb를 f에 대입할 수 있고
// f의 반환값은 위에서 하던 대로 a에 대입할 수 있어야 한다.
f = fb
a = f()
인자형에 반변적
두 함수의 반환형을 고려하지 않을 때, 인자형의 포함 관계의 역전이 전체 함수 타입의 포함 관계를 결정합니다. 이 성질을 반변성(contravariance)이라고 합니다.
왜 함수 타입은 인자형에 반변적일까요? 바로 인자가 lvalue로 쓰이기 때문입니다. 어떤 함수가 더 작은 타입의 함수로 치환이 되더라도, 인자를 받는 데 문제가 없어야 하는데 반변성은 이를 보장해 줍니다.
const fa: (a: A) => void
const fb: (b: B) => void
let f: (b: B) => void
// b는 A의 서브타입인 B 타입이므로, A에 대입이 가능
f = fa
f(b)
한편 함수 인자의 길이도 고려를 해야 하는데요. 이것 역시 직관과 반대로 돌아갑니다.
인자가 적은 함수 타입 인자가 많은 함수 타입
왜 그럴까요? 인자가 적은 함수는 더 많은 인자를 받더라도 문제가 안되는 반면, 인자를 많이 요구하는 함수가 더 적게 받으면 안 되기 때문입니다.
function consume1Arg(x: X): void
function consume2Arg(x: X, y: Y): void
let wide: (x: X, y: Y) => void
wide = consume1Arg
wide(x, y) // consume1Arg는 y를 무시함
let narrow: (x: X) => void
narrow = consume2Arg
// Target signature provides too few arguments. Expected 2 or more, but got 1
narrow(x) // consume2Arg의 y가 결정되지 못함
특수 타입
특수 타입이란 JS에서 값으로 존재하지 않고 TS에서만 존재하는 타입인 never, unknown, any, void를 이릅니다.
never, unknown
우리는 값의 안전한 대입을 타입의 대소 관계로서 다루기로 했습니다. 이 관점에서 never와 unknown은 아주 간단합니다.
모든 타입 T에 대하여, never는 T의 서브타입이며, T는 unknown의 서브타입입니다.
never는 존재할 수 있는 가장 좁은 타입으로, 그 어떤 값도 대입할 수 없습니다. 심지어 undefined도요. never는 일반적인 상황에선 거의 쓰이지 않지만, 복잡한 제네릭을 구성 시 잘못된 대입에 대한 징벌적 오류를 발생시킬 때 유용합니다.
한편 unknown은 존재할 수 있는 가장 넓은 타입으로, 그 어떤 값도 대입할 수 있습니다. 심지어 never 타입으로 강제로 형변환한 값도요.
const thisIsNever: never = undefined
// Type 'undefined' is not assignable to type 'never'.
const thisIsUnknown: unknown = 0
// OK
const neverCantReceiveAnything: never = {} as unknown
// Type 'unknown' is not assignable to type 'never'.
const unknownCanReceiveAnything: unknown = {} as never
// OK
const unknownCantBeAssigned: number = 0 as unknown
// Type 'unknown' is not assignable to type 'number'.
any
그렇다면 any는 무엇일까요?
never를 제외한 모든 타입 T에 대하여, any는 T와 서로 서브타입 관계입니다.
서로 서브타입 관계이기 때문에 any를 number에 대입할 수도 있고, number를 any에 대입할 수도 있습니다. 단, never에 any를 대입할 수는 없습니다.
void
void는 함수의 반환형을 서술할 때 유의미한 타입으로, undefined의 슈퍼타입입니다. undefined과 특수 타입을 제외한 모든 타입과는 무관계입니다.
즉, undefined를 void에는 대입할 수 있지만 그 역은 안 됩니다. 이는 함수를 정의할 때를 생각하면 합리적인데요, void형 함수에 return 문은 사실상 return undefined와 동일하기 때문입니다. 하지만 이 함수의 반환값이 사용되지 않아야 하므로, 다른 타입에 대입은 불가능합니다.
function f(): void {
return undefined // OK
}
const x: number = f()
// Type 'void' is not assignable to type 'number'.
const y: undefined = f()
// Type 'void' is not assignable to type 'undefined'.
퀴즈
다음 중 이론상 가장 넓은 함수의 타입은?
(...args: unknown[]) => unknown(...args: never[]) => unknown(...args: any[]) => any(...args: void[]) => never
정답 및 풀이
정답은 2번입니다.
- 함수의 타입 중 가장 넓은 타입을 찾아야 하므로, 우선 반환형이 좁은
never(4번)은 아웃입니다. - 인자 개수는 모두 무한하게 같고, 인자형 중 가장 좁은 타입을 찾아야 합니다.
- 배열은 개별 원소 타입에 공변적이므로, 개별 요소 중 가장 좁은 타입을 찾아야 합니다.
- 따라서 가장 좁은
never를 가진 2번이 정답입니다.
실제로 이 타입은 함수 타입의 예시로서 TS 공식 문서에 종종 등장하곤 합니다.
마치며
TS의 궁극적인 목표는 안전한 대입과 참조를 실현하는 것입니다. 이를 위해서 타입과 그 대소 관계를 정의하고, 안전한 대입이 무엇인지 살펴보았습니다. 다음 글에서는 TS가 어떻게 안전하지 않은 대입이나 참조를 잡아내는지 알아보겠습니다.
infer, never만 보면 두려워지는 당신을 위한 타입 추론 시리즈