Episode 1: 들어가기에 앞서
여러분들은 타입스크립트를 얼마나 알고 있나요?
type Animal = (Dpg | Cat) & Commonanimal
type Animals<Animal> = Record<string, Animal[]>
type AnimalColors<Animals> = Animals extends [infer T extends Animals, ...infer Rest]
? [T['color'], ...AnimalColors<Rest>]
: []
type ToIntersection<T> = (T extends any
? (_: T) => void
: never
) extends (_: infer S) => void
? S
: never
위 코드들에 대해서 얼마나 이해할 수 있는가?
위 두 개의 코드를 제외하고 이해가 잘 가지 않지만 이 강의가 끝날 때 쯤이면 전부 이해할 수 있기를 바란다.
Episode 2: 타입이론과 TypeScript
때는 바야흐로 1901년...
러셀의 역설(Russell's Paradox)의 발견
자기 자신을 포함하지 않는 모든 집합의 집합 R을 상상하자.
만약 R이 R에 속한다면,
R은 자기자신에 속하지 않는 게 아니므로 모순이다.
만약 R이 R에 속하지 않는다면,
R은 자기자신에 속하지 않는 집합이므로 R에 속해야 한다.
그러니 모순이다.
그러므로 그런 집합은 존재할 수 없다.
수학기초론의 태동
- 19세기 당시 수학계는 생각보다 엄밀하지 않았음
- 모든 수학의 기초인 집합론이 흔들리면서 패닉
- 20세기부터 수학적 엄밀함을 정의하려는 시도
- ZF(C) 공리계, 괴델의 불완전성 정리, 람다대수 등 현대수학이 꽃피기 시작
- 진공관의 발명과 맞물려 컴퓨터의 탄생까지 간접적으로 이어짐
컴퓨터가 탄생할 때 위의 역설이 영향을 미쳐, 타입이 탄생하게 되었다.
타입 이론의 탄생
- 앨런조 처치(Alanzo Church)의 고민 - 함수란 무엇인가?
- 람다대수(Lambda Calculus)를 고안
- 연구하다보니 타입의 개념이 필요함을 깨달음
- 이 외의 다양한 수학자들이 유사한 시도
- 프로그래밍 언어라는 것이 존재하기도 전에 탄생
- FYI, https://plato.stanford.edu/entries/type-theory/
이전까지는 함수란 집합과 집합의 관계로만 생각을 했었다.
람다대수의 이론적인 근거를 제안하게 된다.
이를 연구하다 보니 타입이 필요하다는 것을 깨달았다.
TypeScript와 타입 이론
- TypeScript 1.8까지는 공식문서가 존재했으나, 이후 Microsoft가 문서 유지보수 포기
- tsc의 구현이 곧 TypeScript의 스펙
- 본 강의는 tsc를 블랙박스로 간주하고, 수학적 일관성과 실험을 근거로 서술
- 저는 TypeScript 팀의 입장을 대변하지 않습니다.
Episode 3: 기초 타입 추론
각 형별 타입 추론 원리를 알아봅시다.
타입이란?
어떤 심볼(Symbol, =변수명)에 엮인(Binded)
메모리 공간에 존재할 수 있는
값(Value)의 집합과
그 값들이 가질 수 있는 성질(Properties)
ex)
3.141592 : number
값 3.41592
는 타입 number
에 속한다.
부등호의 의미는 타입이 서로 포함 관계라는 뜻이다.
오른쪽 타입이 더 크고 넓은 타입이라는 뜻이다.
타입 { x: number; y?: string }
은 타입 { x: number }
의 서브타입 이다. (반대는 슈퍼 타입)
타입 B가 가지는 모든 속성을 A도 가지면, 타입 A는 B의 서브타입이다.
타입은 부분순서집합(Partially Ordered Set) 이다
- 실수는 a > b 이거나 a = b 이거나 a < b
타입과 값의 대입
- rval의 타입 ≼ _lval의 타입_일 때 lval := rval가 가능
const x: number = 42 // (OK)
const x: string = 42 // (X)
const x: string | number = 42 // (OK)
- 타입 비교는 대입 상황이 발생할 때 수행
- lval의 타입을 근거로 rval의 서브타입인지 확인
원시 타입 ( Primitive Type)
- boolean, number, string, symbol, undefined, null
- 공리적으로 정의
- 이들 간에는 어떠한 관계도 없음 (서로 무관계)
null
을 제외하곤, 값에typeof
를 수행했을 때 해당 타입 이름이 나옴
리터럴 타입(Literal Type)
- 어떤 타입에 속한 값 하나만으로 구성하는 타입
- 본래 타입의 서브 타입으로 간주
- ex) 6, { srawberry: 'delicious' }
as const
로 특정 값을 리터럴 타입으로 선언할 수 있음
const x = 6 as const
원래 x 의 타입은 number 이지만, as const 를 붙이면 6 이라는 타입으로 정의된다.
객체도 마찬가지이다.
객체 타입 (Object Type)
- lval의 타입 L, rval의 타입을 R이라 하자.
- L의 모든 속성 P에 대하여 L[P] > R[P]이면 L > R ( 여기서 > 는 ≲를 거꾸로 한 것과 같다)
- 이때 객체의 타입은 속성의 타입에 대해 공변적(Covariant)이라고 한다.
{ x: number y?: string z: boolean } |
{ x: number y: undefined z: false a: 'foo' } |
위는 L > P 의 관계이다.
= P 는 L 의 서브타입이다.
배열/튜플 타입 (Array/Tuple Type)
- 객체와 동일하나, number를 키로 갖는다는 점이 다름
- 튜플은 length가 상수 리터럴 타입으로 고정
키 타입 (keyof)
- 객체 타입의 속성의 합집합 (|) 으로 이루어진 타입
- 가질 수 있는 가장 넓은 타입은 number, string, symbol
- 명시적인 타입이 주어진 경우, 필드명의 union
type k = "x" | "y"
type k = keyof { x: number, y?: string }
위 x 타입의 슈퍼 타입은 아래와 같다.
keyof x < number | string | symbol;
함수 타입 (Function Type)
- 반환형과 인자형의 대입 조건을 모두 만족해야 함
- 반환형에 대해서는 공변적(Covariant)
- 인자형에 대해서는 반변적(Contravariant)
함수 타입 (Function Type) - 반환형
A가 B의 서브타입이라면
인자형이 같을 때
A를 반환하는 함수가 B를 반환하는 함수의 서브타입이다.
- 반환형에 대해서 공변적인 이유?
- 반환값은 rvalue로 사용
- f < g 라고 할 때,
let lval = g(x)
라면lval = f(x)
도 가능해야 하기 때문
함수 타입 (Function Type) - 인자형
A가 B의 서브타입이라면
반환형이 같을 때
B를 인자로 갖는 함수는
A를 인자로 갖는 함수의 서브타입이다.
- 인자형에 대해서 반변적인 이유?
- f < g 라고 하자.
- g(x)가 유효한 대입이라면 f(x)도 유효해야 함
- 구체적인 것(f)을 추상적인 것(g)에 대입했으므로
- 그런데 f의 인자가 g의 인자보다 좁은 타입만 허용하면, f(x)가 유효하지 않을 수가 있음
예시)
function processNumber(x: number) {}
function processNumberAndString(x: number | string) {}
let wide: (x: number) => void;
wide = processNumber; // (O)
wide = processNumberAndString; // (O)
let narrow: (x: string | number) => void;
narrow = processNumber; // (X)
narrow = processNumberAndString; // (O)
아래와 같은 타입 에러가 발생한다.
Type '(x: number) => void' is not assignable to type '(x: string | number) => void'.
Types of parameters 'x' and 'x' are incompatible.
Type 'string | number' is not assignable to type 'number'
Type 'string' is not assignable to type 'number'.
- 인자의 수가 불일치하는 경우
- 인자가 적은 함수를 인자가 많은 함수 타입에 대입할 수 있음
- 거꾸로는 안됨
- 왜?
- 그 함수를 사용하는 곳에서 "넣어줘야 할 인자를 안넣어주는 상황"을 방지하기 위함
예시)
function consumeOneArg(x: any) {}
let wide: (x: any, y: any) => void;
wide = consumeOneArg;
wide(0, 1); // consumeOneArg(0, 1), ignore 1
function consumeTwoArg(x: any, y: any) {}
let narrow: (x: any) => void;
narrow = consumeTwoArg;
narrow(0); // consumeTwoArg(0, ?)
아래와 같은 타입 에러가 발생한다.
Type '(x: any, y: any) => void' is not assignable to type '(x: any) => void'.
Target signature provides too few arguments. Expected 2 or more, but got 1.
특수 타입
- never, unknown, any, void
- void는 부록 참고
- 여기서는 undefined하고 비슷하다고만 이해하시면 됩니다
never와 unknown
어떤 타입에 대해서도 never < T < unknown이 성립한다.
- never는 가장 좁은 타입
- 값의 집합이 공집합
- 타입 에러를 고의적으로 발생시킬 때에도 사용 (제네릭에서 다시 설명)
- unknown은 가장 넓은 타입
- any, unknown을 제외하고는 어떠한 곳에도 대입이 불가능함
const thisIsNever: never = 0; // (X)
const thisIsUnknown: unknown = 0; // (O)
const neverCantReceiveAnything: never = {} as unknown; // (X)
const unknownCanReceiveAnything: unknown = {} as unknown; // (O)
const unknownCantBeAssigned: number = 0 as unknown; // (X)
any
never를 제외한 모든 타입은 any와 서로 서브타입이다.
단, never는 any의 서브타입이며 그 역을 성립하지 않는다.
퀴즈
- 다음 중 이론 상 가장 넓은 함수의 타입은?
(...args: unknown[]) => unknown
(...args: never[]) => unknown
(...args: any[]) => any
(...args: void[]) => never
2번????????????????????? 와 맞았다
해석 )
반환형은 공변성을 갖기 때문에 넓은 타입이 넓은 함수다.
인자는 반변성을 갖기 때문에 좁은 타입이 넓은 함수다.
참고로 2번은 존재할 수 있는 모든 함수들의 슈퍼 타입이다.
Episode 4: 고급 타입 추론
TypeScript 제네릭은 튜링완전어쩌구저쩌구...
타입 검사 ( Type Checking )
- 어떤 전체/부분 심볼에 대한 대입, 연산, 참조가 가능한지 확인하는 과정
- 모든 심볼이 제약 조건(=타입)을 만족하는가?
- 어떤 코드 맥락에서, 심볼이 가질 수 있는 타입은 어떤 것이 있는가? ( ex. Type Guard가 된 if문 블록 )
- 제약조건을 갖는 만족 가능성 문제(Constrained Satisfaction Problem, CSP)와 동치
- tsx가 SAT solver 혹은 CSP solver를 구현하는 것은 아님
- SAT 문제는 NP-완전: 이론상 지수시간복잡도가 발생
- 그리고 제네릭을 가지고 놀다보면 종종 그런 케이스를 마주할 때도 있음 ( 이 경우 tsc가 오답을 도출 )
- 완벽하게 풀기엔 문제의 크기가 너무 크다!
- tsc는 Greedy 알고리즘을 사용하는 것으로 추정, 극도로 제한된 추론 깊이 ( ~100 )
const x: number = 'a';
위 코드를 타입스크립트 컴파일러는 어떻게 받아들일까?
string literal 'a' -> 'a': string
declare number const x -> : number
define x as rvalue
-> number > string 이라고 추론
하지만 실제로는 number 와 string 은 포함관계가 아님
contradiction 발생
defined x as rvalue 에서 문제가 발생했다는 것을 추론하여 빨간 줄 발생
제네릭 ( Generic )
- 타입에 대한 함수이자 관계 그 자체
- 1차 논리만 서술 가능
- 즉 고차함수는 허용하지 않음
- 예를 들면 제네릭의 제네릭 같은 거 ( ex: F<T> = T<number> )
제네릭에 제네릭을 넣을 수는 없다.
명시적 타입 전달 ( Explicit Type Argument Passing )
- 제네릭 타입 인자에 직접 타입을 기술 ( ex.
useState<{ x?: number }>({})
) - 전달한 정보를 전제로 사용
- 컨트롤 플로우 상 제네릭이 선언된 심볼 다음부터는 전달한 대로 간주
- 엔드유저들이 사용하는 주된 방식
let x: {x?: number}
x = identity<{ x?: number }>({ x: 'hi' })
declare { x?: number }
type variable x
literal { x: 'hi' } ➡ { x: 'hi } : { x: string } ➡ { x?: number } > { x: string }
call identity <{ x?: number }> ➡ { x?: number } > typeof param0
number | undefined > string ?
contradiction ( 증명 실패 )
만약 제네릭을 적어주지 않으면 컴파일러가 타입을 어떻게 추론할까?
타입 인자 추론 ( Type Argument Inference )
- 타입 선언 안 한 경우 / 제네릭 타입 인자 생략한 경우 / infer 문 사용한 경우
- 컨트롤 플로우 분석으로 수집한 전제로, 해당 타입 인자를 추론
- 최대한 비관적이고 보수적으로 분석
- ex. 만약 rval 타입이 string 이라고 가정해보자.
- lval 타입에는 string 의 수퍼타입이 올 수 있다. string | undefined, any, ...
- 이때 가장 좁은 타입을 lval 로 추론한다. ➡ string
- Greedy
- 완벽하게 추론이 불가능
- 라이브러리 제작자가 자주 고려해야할 방식
- 함수 시그니쳐 오버로딩에서 강력
- 사용자들이 최대한 번거롭게 제네릭을 쓰지 않기 원함
- 함수 시그니쳐 같은 경우에는 다른 시그니쳐도 여럿 쓸 수 있다.
- 이때 타입 인자 추론에 대한 감각이 있어야 순서를 서술하는데 민감하게 다룰 수 있다.
예시 )
function f<T>(value: Promise<T>): T
function f<T>(value: T): T[]
function f<T>(value: number): string // f(3) 추론 시 무시
const x = f(3)
f(3) 호출 시 3번째 함수가 호출 되어야 하는 거 아니야? 라고 생각할 수 있다.
하지만 타입 인자 추론은 Greedy 하기 때문에 위부터 아래의 방향으로 추론하게 된다.
call f(3)
satisfies f<T>(value: Promise<T>): T ?
exists Promise<T> > number ? ➡ NO
satisfies f<T>(value: T): T[] ?
exists T > number ? ➡ YES
f(3): number[]
infer 에 대해서 자세히 알아보자.
조건부 타입 ( Conditional Type )
- 어떤 타입이 다른 타입의 서브타입인지 확인
- 그 여부에 따라서 다른 타입으로 변환
- 생각보다 Greedy하고 일관성 없음 - 왜 안되는지 모를 때가 많음 (부록참고)
type IsNever<T> = [T] extends [never] ? true : false
extends 키워드나 infer 라는 키워드를 가질 수 있다.
이 조건이 만족이 되었을 때 true 나 false 를 반환한다.
갑자기 강의가 생략됨 ... ㅠㅠ
Episode 5: 응용 문제
라이브러리 개발자 체험해보기
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;
}
위의 함수가 하는 것을 설명하는 사진이다.
타입을 어떻게 지정해야 할까?
중첩되지 않은 값과 중첩된 값이 있다.
맨 처음 해야하는 일은 그 둘을 분리해야 하는 것이다.
분리하는 타입을 SimpleFlattendObject 로 만들어보자.
FliterValue 에서는 중첩된 객체일 땐 never, 아니면 T 를 반환한다.
type SimpleFlattendObject<T extends object> = {
[K in keyof T]: FilterValue<T[K]>;
};
type FilerValue<T> = T extends object ? (T extends unknown[] ? T : never) : T;
위의 코드를 타입으로 걸어보면 에러가 발생한다.
왜?
value 에 never 를 걸어도 key 값이 존재하기 때문이다.
value 만 날리는 게 아니라 key 값도 같이 날려야 한다.
FilterPrimitiveKeys 타입을 만들어보자.
이때 이 타입에는 Key 뿐만 아니라 T 값도 필요하다.
K 를 대입할 모체 타입이 필요하기 때문이다.
type SimpleFalttendObject<T extends object> = {
[K in FilterPrimitiveKeys<T, keyof T>]: T[K];
};
type FilterPrimitiveKeys<T, K> = K extends keyof T
? T[K] extends object
? T[K] extends unknown[]
? K
: never
: K
: never;
반대로 중첩된 객체 타입만 반환하는 타입도 만들어보자.
type NestedObject<T extends object> = {
[K in FilterNestedKeys<T, keyof T>]: T[K];
};
type FilterNestedKeys<T, K> = K extends keyof T
? T[K] extends object
? T[K] extends unknown[]
? never
: K
: never
: never;
중첩된 객체의 depth 를 들어내는 타입은 어떻게 만들까?
type Values<T extends object> = T[keyof T];
/*
T['x' | 'y' | 'a' | 'b' | 'h']
=> T['x'] | T['y'] | ...
=> number | { z: string } | ...
*/
이것을 그대로 사용하는 것이 아닌 중첩된 객체들만 가져다 사용해보자.
type UnwrappedObject<T extends object> = Values<NestedObject<T>>;
좀 더 예쁘게 타입이 정리 되었다.
이 타입들이 union이 아닌 인터섹션으로 바뀌기만 하면 모든 게 해결될 것 같다.
이걸 어떻게 하면 바꿀 수 있을까?
stackoverflow 에 코드가 있다.
type ToIntersection<T> = (T extends any ? (_: T) => void : never) extends (
_: infer S,
) => void
? S
: never;
차근차근 읽어보자.
................어렵다
T ==> T extends any ? 항상 만족 하는데 왜 조건을 걸었을까?
우리의 궁극적인 목적은 union 형식을 intersection으로 바꾸는 것이다.
위에서 number | string 이런 형태의 union이 있었는데 해당 조건부를 걸지 않고 바로 (_:T) => void 형태로 가게 된다면
(_: stirng | number) => void 로 추론이 된다. (추론의 의미가 없다.)
만약 조건을 걸게 된다면 distributive law 라는 분배 법칙이 적용되기 때문에 개별로 따로 따로 조건이 적용된다.
((_: number) => void | ((_: string) => void)
그렇기 때문에 항상 만족하는 조건을 넣은 것이다.
위에서 걸러진 ((_: number) => void | ((_: string) => void) 타입이 (_: infer S) => void 의 서브타입인지 아닌지 체크를 한다.
서브타입이면 S 를 반환한다.
여기서 깊게 궁금한 사람이 있다면 수학과에 가서 집합론을 들어야 한다...🥹
함수의 인자는 반변적이기 때문에 number와 string 을 둘다 만족시킬 수 있는 인자 타입이어야 한다.
그렇기 때문에 S 가 number 와 string 보다 작아져야 한다.
number & string ==> infer S
type X = UnwrappedObject<{
x: number,
y: {
z: string,
},
a: null,
b: [1],
h: {
hh: 'hh',
}
}>
type Y = ToIntersection<X>
유니언이 앤드로 바뀌었다 !!
type UnwrappedObject<T extends object> = ToIntersection<Values<NestedObject<T>>>
최종적인 코드
type FlattendObject<T extends object> = SimpleFlattendObject<T> &
UnwrappedObject<T>;
하지만 위의 타입은 디버깅하기 어렵다.
type Roll<T> = {
[K in keyof T]: T[K]
} & {}
type X = Roll<FlattendObject<{
x: number,
y: {
z: string,
},
a: null,
b: [1],
h: {
hh: 'hh',
}
}>>
Roll 타입을 추가하면 자동으로 추론을 해준다.
하지만 좀 더 depth 가 깊어지면 flatten 이 되지 않는다.
이걸 풀기 위해서는 재귀를 사용해야 한다.
이때 위에서 했던 UnwrappedObject 타입을 다시 보자.
이때는 depth 를 풀기 전 객체로 전부 엮여있던 상황이다.
인터섹션을 가기 전에 Unwrapped 에서 다 풀어버리면 되는 거 아닐까?
intersection 처리를 하기 전에 {} | {} | {}
객체로 처리가 되었었다.
toIntersection 을 거치고 나면 {} & {} & {}
형식으로 바뀌었다.
이때 객체 안에 또 다른 객체가 들어있을 수 있다.
FlattendObject 과정에서 객체를 전부 풀어보면 어떨까?
이렇게 하면 무한 루프가 발생한다.
여기에 삼항 연산자라를 넣어볼까? (=지연 평가)
타입스크립트 제네릭 또한 컴파일러가 인지 ➡ 평가를 해야 하는 구조
만나자마자 평가 or 지연 시켜서 컴파일러가 돌아가는 런타임 중에 평가를 하는 2가지 방법이 있다.
2번째 방법 중 대표적인 방법
제네릭 타입에서 conditioned type 을 사용하는 것.
➡ 지연 평가가 이루어진다.
type RecursionHelper<T> = T extends object ?
? FlattendObject<T>
: never;
제네릭에 object 타입만 넣어줄 것이기 때문에 T extends obejct
조건이 필요한 건 아니지만 지연 평가를 위해 넣는 것이다.
자, 이제 다 끝났다.
Roll<FlttendObject>
타입을 함수에 갖다 붙이면 된다.
function flattenObject<T extends object>(obj: T, result: any = {}): Roll<FlattendObject<T>> {
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;
}
위와 같이 타입을 변경하면, 함수 내부에서 타입 에러가 발생한다. (TypeScript 가 완벽하지 않다는 증거)
typeof obj[key] === 'object'
라는 조건을 거침에도 불구하고 flattenObject(obj[key], result)
에서 obj[key] 의 타입이 맞지 않다는 에러가 발생한다.
강사분은 @ts-ignore 를 사용했지만, 더 나은 방법이 있다면 좋을 것 같다.
기적적으로 모든 타입들이 추론되는 것을 볼 수 있다.
지금까지 나온 코드 정리
🤔 이 정도로 정밀하게 타입 추론을 해야 할까?
라이브러리 개발자 or 사내 니즈에 따라 하면 된다.
허들이 있기 때문에 가독성이 좋진 않지만 안정적으로 사용할 수 있다.
양날의 검이기 때문에 선택해서 사용하면 될 것 같다.
'TypeScript' 카테고리의 다른 글
[TypeScript] 기본 타입 (0) | 2022.09.13 |
---|---|
[TypeScript] TypeScript 시작하기 (0) | 2022.09.06 |
Comment