타입스크립트 타입 체커와 관련한 깨달음 1
서론
타입스크립트 타입 체커와 관련해서 한가지 사실을 깨달았고, 이렇게 오래 고민한 문제는 오랜만이라 기록으로 남긴다. 그리고 깨달음 1인 이유는 아직 고칠 게 너무 많아서이다.
문제
declare function foo(x: string): number;
declare function foo(x: number): string;
function g1() {
let x: string | number | boolean;
x = "";
while (cond) {
x = foo(x);
x;
}
x;
}
이 문제를 이해하기 위해 필요한 배경 지식이 좀 있다.
배경 지식: 실제 타입
타입스크립트에서의 변수는 타입을 2개 가진다. 하나는 선언 타입, 또 다른 하나는 실제 타입이다. 선언 타입은 변수에 할당할 때 쓰이는 타입이고 실제 타입은 변수를 사용할 때 쓰이는 타입이다.
이 구분은 아래와 같은 코드를 처리하기 위해서 쓰인다.
function stringOnly(s: string) {}
let x: string | number;
x = "foo";
stringOnly(x);
x가 string | number
로 선언되었기 때문에 많은 언어에서는 타입 에러가 났을 것이다.
하지만 타입스크립트는 실제 타입 / 선언 타입이 분리되어있기 때문에 위 코드는 정상적으로 컴파일된다.
타입스크립트를 재구현하는 입장에서는 좀 골치가 아프지만, 이건 실제 타입울 저장해놓으면 되기 때문에 큰 문제는 아니다. 문제는 반복문과 섞였을 때 발생한다.
declare function foo(x: string): number;
declare function foo(x: number): string;
function g1() {
let x: string | number | boolean;
x = "";
while (cond) {
x = foo(x);
x;
}
x;
}
문제의 코드를 다시 보자.
while 루프가 시작할 때 x
는 string
이다.
따라서 foo(x)
는 첫번째 함수를 호출하고 반환값은 number
가 된다.
그럼 이제 x는 number
이다.
2번째 루프에서 x
는 number
일 것이다.
foo(x)
의 타입은 string
이 된다.
따라서, 루프 안에서의 타입을 적어보면
declare function foo(x: string): number;
declare function foo(x: number): string;
function g1() {
let x: string | number | boolean;
x = "";
while (cond) {
// x: string, number 중 하나
x = foo(x);
// x: number, string 중 하나
x;
}
x;
}
이렇게 된다. 순서는 동순이다. (복호동순)
이걸 처리하는 게 생각보다 복잡하다. 그래도 어찌어찌 처리는 했었는데, 일단 기존 처리 방식과 그 문제점부터 얘기하겠다.
기존 처리 방식
난 while
문을 처리할 때 검증 모드를 끄고 x
의 실제 타입을 string
으로 놓고 루프를 검증하고, 해석하면서 나온 정보 (facts
, 여기서는 x = number
)을 다음 루프에 넣도록 했다.
그리고 루프를 해석해봤는데 새로운 정보를 만들어내지 않으면 검증 모드를 켜고 정보(facts
)를 종합한 뒤 루프를 검사한다.
검증 모드를 끄는 건 변수 타입을 정확하게 알 수 없어서 에러가 나기 때문이다.
이렇게 해도 작동은 한다.
문제는 느리다는 점이다.
루프문이 타입 정보(facts
)를 만들어낼지 안 만들어낼지 알 수 없기 때문에 모든 루프를 2번 이상 해석해야하고, 반복문이 중첩되면 해석 횟수가 n1 * n2 * n3처럼 늘어난다.
빠를 수가 없는 구조다.
물론 러스트니까 이래도 tsc
보단 함참 빠르다.
근데 언어가 아무리 러스트여도 O(n1 * n2 * n3)
꼴의 시간 복잡도는 부답스럽다.
tsc
그래서 타입스크립트 소스 코드를 봤다.
근데 checkWhileStatement
소스 코드를 보니끼 한번만 검증하더라.
function checkWhileStatement(node: WhileStatement) {
// Grammar checking
checkGrammarStatementInAmbientContext(node);
checkTruthinessExpression(node.expression);
checkSourceElement(node.statement);
}
어떻게 이렇게 하는지 한참 고민했다 개발할 때 고민 잘 안 하는 스타일인데 이건 이게 어떻게 가능한지 직관적으로 생각이 안 났다.
깨달음
그러다가 생각난 게 있는데, 루프를 여러 번 해석할 필요가 없다는 점이 그것이다.
어차피 검증 모드를 켜고 에러를 생성하는 건 마지막 루프 딱 한번이다.
그리고 그때 루프에 넣어주는 x의 타입은 string | number
이다.
그렇다면 x = foo(x);
만 여러번 해석해도 된다.
x = foo(x)
의 첫번째 해석해서 x = number
라는 걸 뽑아내는 건 동일하다.
그런데 루프문 해석기에 정보를 넘겨주는 대신에 x = number
로 놓고 x = foo(x)
를 다시 해석할 수 있다.
그러면 x = string
이라는 정보가 나오므로, x = string | number
이라는 걸 한번에 알 수 있다.
물론 다른 언어에선 x가 string
인 경우와 number
인 경우를 나눠야할 수도 있지만, 공식 tsc
는 그런 걸 지원하지 않는다.
이 새로운 처리 방식은 tsc
의 할당 표현식 처리 함수가 트램폴린이라는 개념을 쓰는 걸 보고 알게 된 것이다.
쉽게 말하면 tsc
가 이런 트릭을 쓰는 것 같았다.
(코드가 너무 방대한 관계로 제대로 분석하진 않았다.)
따라서 타입스크립트 구현자 입장에서는 이 정도면 충분하다.
이것 때문에 느낀 점이 있는데, 제대로 포팅을 할 거라면 원본 소스를 어느 정도는 봐야한다는 걸 느꼈다. 난 원래 테스트 코드만 복붙해와서 테스트 하나씩 고쳐가는 식으로 아예 다시 구현을 해버린다. 주된 이유는 그게 재밌어서인데... 이제 재미만으로 하는 프로젝트가 아니니까 재미 없더라도 참고 원본 소스를 참고할 생각이다.