유튜브에 공개된 네이버 사내 기술 교류 행사인 NAVER ENGINEERING DAY 2024(5월)에서 발표되었던 세션을 보고 타입스크립트의 타입 시스템에 대한 이해가 깊어졌습니다. 해당 발표 영상을 보고 추후 다시 찾아볼 수 있도록 정리한 내용입니다. 본문에는 제 나름 대로의 해석이 추가되어 오해의 여지가 있을 수 있습니다.
발표 영상: infer, never만 보면 두려워지는 당신을 위한 고급 TypeScript
타입이론과 TypeScript
러셀의 역설(Russell's Paradox)의 발견으로 수학의 근간인 집합론이 흔들리며 함수에 대한 고찰도 이루어졌다.
기존 집합 사이의 관계로만 생각했던 함수에 대한 고찰을 시작은 람다 대수(Lambda Calculus)의 고안을 이끌었고 이를 고도화하던 중 타입의 개념이 도입되었다. 관련 내용은 해당 문서에 상세히 나와있다. https://plato.stanford.edu/entries/type-theory/
놀랍게도 TypeScript는 1.8까지 공식문서가 존재했으나 Microsoft가 문서 유지보수를 포기하며 tsc의 구현이 곧 TypesScript의 스펙이 되었다.
해당 영상은 tsc를 블랙박스로 간주하고 수학적 일관성과 실험을 근거로 서술한다.
기초 타입 추론
슈퍼타입과 서브타입
영상에서는 타입을 `어떤 심벌(Symbol, ≒ 변수명)에 엮인(binded) 메모리 공간에 존재할 수 있는 값(value)의 집합과 그 값들이 가질 수 있는 성질(properties)`로 정의한다.
`3.141592 : number` 이와 같은 표기는 `값 3.141592는 타입 number에 속한다.`로 이해할 수 있다.
$$\{\text{x}:\text{number};\thinspace\text{y}?:\text{string} \}\lesssim\{\text{x}:\text{number}\}$$
타입 $\{\text{x}:\text{number};\thinspace\text{y}?:\text{string}\}$은 타입 $\{\text{x}:\text{number}\}$에 속한다.
타입 $\{\text{x}:\text{number};\thinspace\text{y}?:\text{string} \}$은 타입 $\{\text{x}:\text{number}\}$의 서브타입이다.
타입 $\{\text{x}:\text{number}\}$은 타입 $\{\text{x}:\text{number};\thinspace\text{y}?:\text{string}\}$의 슈퍼타입이다.
`타입 B가 가지는 모든 속성을 타입 A가 가지면, 타입 A는 B의 서브타입이다.`
`서브타입은 슈퍼타입의 모든 속성을 갖는다.`
타입 간의 관계와 대입
타입은 4가지 관계를 가질 수 있다.
- $\text{a} \gtrsim \text{b}$: a가 b의 슈퍼타입이다. ( $\text{number} \gtrsim 42$ )
- $\text{a} \lesssim \text{b}$: a가 b의 서브타입이다. ( $\text{string} \lesssim \text{string \textbar number}$ )
- $\text{a} \simeq \text{b}$: a와 b가 서로 서브타입이다. ( $\{\text{x}?:\text{number}\}\simeq \{\text{x}:\text{number \textbar \text{undefined}}\}$ )
- $\text{a} \not\simeq \text{b}$: 서로 관계가 없다. ( $\text{number} \not\simeq \{\text{x}:\text{string}\}$ )
작은 타입을 큰 타입에 대입할 수 있다.
`서브타입을 슈퍼타입에 대입할 수 있다.`
원시 타입(Primitive Type)
- boolean, number, string, symbol, null, undefined
- 공리적으로 정의
- 상호 무관계
- null을 제외한 모든 원시 타입은 `typeof`연산 결과 해당 타입의 이름이 나온다.
리터럴 타입(Literal Type)
- 어떤 타입에 속한 값 하나만으로 구성하는 타입
- 본래 타입의 서브타입으로 간주
- as const 키워드로 특정 값을 리터럴 타입으로 선언할 수 있다.
const x = 6; // x: number (원시 타입으로 추론)
const y = 6 as const; // y: 6 (리터럴)
객체 타입(Object Type)
- 타입 L과 R에서 타입 L의 모든 속성 P에 대하여 $\text{L[P]}\gtrsim \text{R[P]}$이면 $\text{L}\gtrsim\text{R}$이다.
- 이때 객체의 타입은 속성의 타입에 대해 `공변적(Covariant)`이라 한다.
배열/튜플 타입(Array/Typle Type)
- 객체와 동일하다. 키가 number로 고정되어 있다는 점만 다르다.
- 튜플은 length가 상수 리터럴 타입으로 고정되어 있다.
$$\text{a}\lesssim\text{b}\Longrightarrow\text{A[ ]}\lesssim\text{B[ ]}$$
함수 타입(Function Type)
- 반환형과 인자형의 대입 조건을 모두 만족해야 한다.
- 반환형에 대해서는 `공변적(Covariant)`
- $\text{A}\lesssim\text{B}\Longrightarrow\text{X}\mapsto\text{A}\lesssim\text{X}\mapsto\text{B}$
- 인자형이 같은 두 함수에서 A가 B의 서브타입이라면 A를 반환하는 함수가 B를 반환하는 함수의 서브타입이다.
- 함수의 타입 관계는 반환형의 타입관계와 같은 방향이다. (공변적이다.)
- 반환값은 rvalue로 사용되기 때문에 함수의 타입과 반환형의 타입은 공변적이다.
- 인자형에 대해서는 `반변적(Contravariant)`
- $\text{A}\lesssim\text{B}\Longrightarrow\text{A}\mapsto\text{X}\gtrsim\text{B}\mapsto\text{X}$
- 반환형이 같은 두 함수에서 A가 B의 서브타입이라면 A를 인자로 갖는 함수는 B를 인자로 갖는 함수의 슈퍼타입이다.
- 인자의 수가 불일치하는 경우
- 인자가 적은 함수를 인자가 많은 함수에 대입할 수 있다.
- 인자가 적은 함수가 서브타입, 인자가 많은 함수가 슈퍼타입이 된다.
- 인터페이스(슈퍼타입, 인자 많은 함수)로 들어오는 넘치는 인자는 구현체(서브타입, 인자 적은 함수)에서 버리면 그만이다.
특수 타입
Javascript에는 존재하지 않으나 TypeScript에서 타입상으로만 존재하는 타입
- never, unknown, any, void
- never & unknown
- $\forall_T:\text{never}\lesssim\text{T}\lesssim\text{unknown}$
- `never`는 가장 좁은 타입이다.
- 모든 타입의 서브타입이다.
- 공집합과 비슷하다.
- 어떤 타입도 대입할 수 없다.
- 타입 에러를 고의적으로 발생시킬 때에도 사용(제네릭에서 사용)
- `unknown`은 가장 넓은 타입이다.
- 모든 타입의 슈퍼타입이다.
- any, unknown을 제외한 어떠한 곳에도 대입이 불가능하다.
- 모든 값을 받을 수는 있지만 대입할 수는 없다.
- any
- $\forall_{\text{T}\not=\text{never}}:\text{T}\simeq\text{any},\,\text{never}\lesssim\text{any}$
- never를 제외한 모든 타입과 서로 서브타입이다.
- never를 제외한 모든 타입에 대입 가능하며 모든 타입을 받을 수 있다.
const thisIsNever: never = undefined; // ERROR: never는 모든 타입의 서브타입이다.
const thisIsUnknown: unknown = 0; // unknown은 모든 타입의 슈퍼타입이다.
퀴즈
// 다음 중 이론 상 가장 넓은 함수의 타입은? (슈퍼타입은?)
type A = (...args: unknown[]) => unknow;
type B = (...args: never[]) => unknow;
type C = (...args: any[]) => any;
type D = (...args: void[]) => never;
`B`는 모든 함수의 슈퍼타입이다.
함수의 타입은 반환형과 공변적, 인자형과 반변적이므로 반환형은 넓고, 인자형은 좁을수록 함수의 타입은 넓어진다.
반환형이 가장 넓은 타입인`unknown`이고 인자형이 가장 좁은 타입인 `never`인 `B`가 가장 넓은 함수 타입이다.
고급 타입 추론
타입 검사(Type Checking)
어떤 전체/부분 심벌에 대한 대입, 연산, 참조가 가능성을 확인하는 과정이다.
- 모든 심벌이 제약 조건(타입)을 만족하는가?
- 어떤 코드 맥락에서, 심벌이 가질 수 있는 타입은 어떤 것인가(type guard에 의한 분기)
타입 검사의 구현은 제약조건을 갖는 만족 가능성 문제(Constrained Satisfaction Problem, CSP)와 동치다. 하지만 SAT 문제(Satisfiability Problem)는 NP-완전 문제로 이론상 지수시간의 시간복잡도를 갖는다.
tsc는 SAT solver 혹은 CSP solver를 구현하지 않고 Greedy 알고리즘을 사용하는 것으로 추정된다. 실제 SAT solver가 아니기 때문에 추론 깊이의 한계가 있고 복잡하고 깊이가 깊은 타입을 구현할 경우 tsc에서 타입 추론을 정확하게 수행하지 못한다.
제네릭(Generic)
- 제네릭은 타입에 대한 함수이자 관계 그 자체다.
- 1차 논리만 서술 가능하다.
- 즉 고차 함수는 허용하지 않는다.
명시적 타입 전달(Explicit Type Argument Passing)
- 제네릭 타입 인자에 직접 타입을 기술
- ex) `useState<{ x?: number}>({})`
- 실행 흐름 상에서 명시적 타입 전달이 된 이후부터는 전달된 타입으로 추론
타입 인자 추론(Type Argument Inference)
- 타입 선언 안 한 경우, 제네릭 타입의 인자를 생략한 경우, infer 문을 사용한 경우
- 실행 흐름상에서 분석된 정보를 바탕으로 타입 인자를 추론
- 최대한 비관적이고 보수적으로 분석
- 가장 작은 타입으로 추론한다.
- Greedy로 추론하기 때문에 완벽하게 추론되지 않는 경우가 있다.
- 라이브러리 제작자가 자주 고려해야 할 방식
- 함수 시그니쳐 오버로딩에서 순서에 민감하다.
- 가장 위부터 만족하는 함수 시그니쳐를 찾고 찾았다면 그 이후 시그니쳐는 확인하지 않는다.
조건부 타입(Conditional Type)
- 어떤 타입이 다른 타입의 서브타입인지 확인
- 그 여부에 따라서 다른 타입으로 변환
- 생각보다 Greedy 하고 일관성 없음 (왜 안되는지 모를 때가 많음)
- `type HasName<T> = T extends { name: string }? true : false`
응용문제
아래 `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;
}
const input = {
x: 0,
y: 'Y',
z: ['Z'],
a: {
b: {
c: null,
},
d: undefined,
},
};
const output = flattenObject(input);
/**
* output = {
* x: 0,
* y: 'Y',
* z: ['Z'],
* c: null,
* d: undefined,
* }
*/
step 1. 평탄화된 속성 타입과 중첩 객체 속성 타입 분리하기
위 함수에서 배열이 아닌 객체 타입을 값으로 가져 재귀적으로 평탄화할 속성(`a`, `b`)과 평탄화가 이미 되어있는 속성(`x`, `y`, `z`, `c`, `d`)을 분류하는 타입을 작성해 보자.
평탄화된 속성 추출하기 1 (값 타입을 never로 추론)
평탄화된 속성을 추출하는 방법으로 중첩 객체를 값으로 갖는 속성의 `값 타입을 never`로 만드는 방법을 생각해 볼 수 있다. 하지만 이 경우 키는 지워지지 않고 키의 값이 never로 추론돼 원하는 결과를 얻지 못한다.
/**
* 객체 타입을 그대로 복사하는 타입
* 이 타입에서 배열이 아닌 객체 타입을 값으로 갖는 속성를 지워보자.
*/
type CopyObject<T extends object> = {
[K in keyof T]: T[K]
};
/**
* 중첩 타입을 값으로 갖는 속성의 `값 타입을 never로 추론`해 해당 속성를 지운다.
* 이 경우 속성는 남고 해당 속성의 값 타입이 never로 추론돼 원하는 결과가 나오지 않는다.
*/
type FilterValue<T> =
T extends object
? T extends unknown[] // 배열 중 가장 큰 타입인 unknown[]으로 배열 타입 검사
? T // 배열 타입 값을 갖는 속성은 값 타입을 그대로 반환
: never // 중첩 객체 값을 갖는 속성의 값 타입을 never로 추론해 속성 삭제 시도
: T; // 객체가 아닌 값을 갖는 속성은 값 타입을 그대로 반환
type ValueFilteredFlattenObject<T extends object> = {
[K in keyof T]: FilterValue<T[K]>
};
const valueFilteredFlattenObject: ValueFilteredFlattenObject<{
x: number,
y: {
z: string
}
}> = {
x: 0,
y: undefined // ERRROR: y의 값 타입이 never로 추론 됐지만 y키 값이 살아 있다.
};
평탄화된 속성 추출하기 2 (키 타입을 never로 추론)
위 `ValueFilteredFlattenObject`에서는 속성이 지워지지 않아 원하는 결과를 얻지 못했다. 값의 타입이 아닌 `속성의 타입을 never로 추론`해 속성을 지우면 원하는 결과를 얻을 수 있다.
/**
* 위 `ValueFilteredFlattenObject`에서는 속성이 지워지지 않아 원하는 결과를 얻지 못했다.
* `속성 타입을 never로 추론`해 속성를 지워보자.
*/
type FilterPrimaryKey<T, K> =
K extends keyof T
? T[K] extends object // 값이 객체 타입인지 확인
? T[K] extends unknown[] // 값이 배열 타입인지 확인
? K // 값이 배열 타입이라면 속성을 필터링하지 않는다.
: never // 값이 배열이 아닌 객체 타입이라면 속성을 필터링한다.
: K // 값이 객체 타입이 아니라면 속성을 필터링하지 않는다.
: never; // K가 T의 속성이 아닌 경우는 무시한다.
type KeyFilteredFlattenObject<T extends object> = {
[K in FilterKey<T, keyof T>]: T[K]
};
const keyFilteredFlattenObject: KeyFilteredFlattenObject<{
x: number,
y: {
z: string
}
}> = {
x: 0,
};
중첩 객체 속성 타입 추출하기
이번엔 반대로 위 `KeyFilteredFlattenObject`에서 삭제한 중첩 속성 타입을 추출해 보자. `FilterPrimayKey`타입에서 필터링하는 부분만 거꾸로 해주면 된다.
type FilterNestedKeys<T, K> =
K extends keyof T
? T[K] extends object // 값이 객체 타입인지 확인
? T[K] extends unknown[] // 값이 객체 타입이라면 배열 타입인지 확인한다.
? never // 값이 배열 타입이라면 키를 필터링한다.
: K // 값이 배열이 아닌 객체 타입이라면 필터링하지 않는다.
: never // 값이 객체 타입이 아니라면 키를 필터링한다.
: never; // K가 T의 키가 아닌 경우는 무시한다.
type NestedObject<T extends object> = {
[K in FilterNestedKeys<T, keyof T>]: T[K]
};
const nestedObject: NestedObject<{
x: number,
y: {
z: string
}
}> = {
y: {
z: 'Z'
}
};
56:39
'Languages > JS∕TS' 카테고리의 다른 글
[TS] How to get type from property of objects in array (0) | 2022.07.02 |
---|---|
[TS] interface, impliments, extends 예시 (0) | 2022.02.23 |
[TS] 기본 자료형 (0) | 2022.02.22 |
[코어 자바스크립트] 메모리 동작과 mutability, immutability (0) | 2021.12.25 |
[JS] 호이스팅과 TDZ (0) | 2021.10.05 |