타입스크립트 타입 체커와 관련한 깨달음 1

Page content

서론

타입스크립트 타입 체커와 관련해서 한가지 사실을 깨달았고, 이렇게 오래 고민한 문제는 오랜만이라 기록으로 남긴다. 그리고 깨달음 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 루프가 시작할 때 xstring이다. 따라서 foo(x)는 첫번째 함수를 호출하고 반환값은 number가 된다. 그럼 이제 x는 number이다.

2번째 루프에서 xnumber일 것이다. 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가 이런 트릭을 쓰는 것 같았다. (코드가 너무 방대한 관계로 제대로 분석하진 않았다.) 따라서 타입스크립트 구현자 입장에서는 이 정도면 충분하다.

이것 때문에 느낀 점이 있는데, 제대로 포팅을 할 거라면 원본 소스를 어느 정도는 봐야한다는 걸 느꼈다. 난 원래 테스트 코드만 복붙해와서 테스트 하나씩 고쳐가는 식으로 아예 다시 구현을 해버린다. 주된 이유는 그게 재밌어서인데… 이제 재미만으로 하는 프로젝트가 아니니까 재미 없더라도 참고 원본 소스를 참고할 생각이다.