avatar
Published on

타입스크립트 컴파일러는 어떻게 동작하는가?

Author
  • avatar
    Name
    yceffort

Table of Contents

Introduction

jQuery와 angular, react의 등장으로 프론트엔드 생태계에 많은 변화가 있었다고 한다면, 타입스크립트도 그에 못지 않은 영향력을 끼쳤다고 볼 수 있다. 타입스크립트의 등장 전후로 프론트엔드 개발, 특히 협업하는 데 있어서 큰 도움을 얻을 수 있었다.

그런데 우리는 타입스크립트는 어떻게 동작할까? tsc라는 명령어 뒤에는 어떤 일이 벌어지고 있을까? 리처드 파인만이 말했던 것처럼, 스스로 만들어 보는 수준까지는 아니더라도, 타입스크립트 컴파일러가 동작하는 방식에 대해서 하나하나씩 뜯어보고, 직접 코드도 살펴보면서 이해해보고자 한다.

참고한 내용

주로 참고한 내용은 tsconf 2021에 있었던 키노트다.

대략적인 흐름

타입스크립트 컴파일러가 동작하는 방식, 즉 tsc 명령어를 눌렀을 때 일어나는 작업은 크게 아래와 같이 나눠볼 수 있다.

  1. tsconfig 읽기: 타입스크립트 프로젝트라면, root에 tsconifg.json을 읽는 작업부터 시작할 것이다.
  2. preprocess: 파일의 root 부터 시작해서 imports로 연결된 가능한 모든 파일을 찾는다.
  3. tokenize & parse: .ts로 작성된 파일을 신택스 트리로 변경한다.
  4. binder: 3번에서 변경한 신택스 트리를 기준으로, 해당 트리에 있는 symbol (const 등) 을 identifier로 변경한다.
  5. 타입체크: binder와 신택스 트리를 기준으로 타입을 체크한다.
  6. transform: 신택스트리를 1번에서 읽었던 옵션에 맞게 변경한다.
  7. emit: 신택스 트리를 .js .d.ts파일 등으로 변경한다.
  • 3번까지의 과정이 소스코드를 읽어 데이터로 만드는 과정
  • 4, 5가 타입체킹 과정
  • 6, 7 을 파일을 만드는 과정이라 볼 수 있다.

소스코드를 데이터로 만들기

1번과 2번 과정을 제외하고, 가장 먼저 해야할 일은 코드를 신택스트리로 변경하는 일이다.

index.ts

const message: string = 'Hello, world!'
welcome(message)

function welcome(str: string) {
  console.log(str)
}

위와 같은 파일이 있다고 가정해보자. 일반적으로 자바스크립트 코드는 ;, 줄바꿈, 내지는 {} 등으로 나눠서 이해할 수 있다. 여기에서는 세가지 구문으로 나눠 볼 수 있다.

  • const message: string = "Hello, world!" 변수를 선언하는 구문
  • welcome(message) 함수를 호출하는 구문
  • function ...{...} 함수를 정의하는 구문

타입스크립트는 일단 이렇게 3가지 구문으로 나누어서 시작할 것이다.

const message: string = 'Hello, world!'

위 코드를 또 자세히 보면, 각각을 다음과 같은 chunk 로 나눌 수 있다.

  • const
  • message
  • :
  • string
  • =
  • "Hello, world!"

이런식으로 일반적인 코드 문자열을 데이터로 만드는 과정이 바로 신택스 트리를 생성하는 과정이라 볼 수 있다. 그리고 이렇게 만들어진 트리가 abstract syntax tree, 즉 추상구문트리라 불리우는 것이다.

그리고 이 신택스 트리를 만들기 위해서 필요한 것이 scannerparser다.

scanner

https://github.com/microsoft/TypeScript/blob/main/src/compiler/scanner.ts: 이 코드를 잘 살펴보면, 코드 문자열을 읽기 위한 사전작업, 예를 들어 예약어 (abstract, case 등)를 읽어들이거나 {}와 같은 토큰을 분석하기 위한 작업들이 준비되어 있는 것을 볼 수 있다. (이 스캐너는 무려 26,000줄의 단일파일로 구성되어 있는데, 이제 앞으로 살펴볼 파일들 대비 귀여운(?)편에 속한다.) 이 스캐너의 역할은 일반적인 코드 문자열을 토큰으로 변환하는 것이다. 위의 토큰은 아래와 같이 변환된다.

  • Const Keyword
  • WhitespaceTrivia
  • Identifier
  • ColonToken
  • WhitespaceTrivia
  • StringKeyword
  • WhitespaceTrivia
  • EqualToken
  • WhitespaceTrivia
  • StringLiteral

tsplayground 에서 확인해보기

우측 사이드바에 scanner가 뜨지 않는다면 plugins에서 scanner

참고로 실제 타입스크립트에서 동작하는 것과 약간의 차이가 있다.

이과정은 굉장히 선형적으로 단순하게 이루어진다. 즉 파일을 처음부터 주욱 읽어 가면서, 특정 키워드내지는 예약어가 있는지, identifier가 있는지, 등을 순차적으로 확인한다.

스캐너는 이 과정에서 코드 문자열의 정합성도 검사한다. 예를 들어 다음과 같은 것들이 있다.

let noEnd = " // Unterminated string literal.(1002)
let num = 2__3  // Multiple consecutive numeric separators are not permitted.(6189)
const 🤔 = 'hello' // Invalid character.(1127)
let x1 =  1} // Declaration or statement expected.(1128)

parser

https://github.com/microsoft/TypeScript/blob/main/src/compiler/parser.ts parser도 비교적 적은 양의 코드인 9,000줄로 구성되어 있다. 이 파서의 역할은, 스캐너가 읽어들인 token을 기준으로 트리를 만드는 것이다.

앞서 언급했던 토큰들은, parser에 의해 아래와 같은 트리로 만들어 진다.

ts-ast

https://ts-ast-viewer.com/#code/MYewdgzgLgBAtgUwhAhgcwQLhtATgSzDRgF4YByACQQBsaQAaGAdxFxoBMBCcgKF6A

AST
SourceFile
    pos: 0
    end: 43
    flags: 0
    modifierFlagsCache: 0
    transformFlags: 2229249
    kind: 303 (SyntaxKind.SourceFile)
    statements: [
    FirstStatement
    ]
    endOfFileToken: EndOfFileToken
    fileName: /input.tsx
    text: const message: string = 'Hello, world!'
    languageVersion: 4
    languageVariant: 1
    scriptKind: 4
    isDeclarationFile: false
    hasNoDefaultLib: false
    externalModuleIndicator: undefined
    bindDiagnostics:
    bindSuggestionDiagnostics: undefined
    pragmas: [object Map]
    checkJsDirective: undefined
    referencedFiles:
    typeReferenceDirectives:
    libReferenceDirectives:
    amdDependencies:
    commentDirectives: undefined
    nodeCount: 8
    identifierCount: 1
    identifiers: [object Map]
    parseDiagnostics:
    path: /input.tsx
    resolvedPath: /input.tsx
    originalFileName: /input.tsx
    impliedNodeFormat: undefined
    imports:
    moduleAugmentations:
    ambientModuleNames:
    resolvedModules: undefined
    locals: [object Map]
    endFlowNode: [object Object]
    symbolCount: 1
    classifiableNames: [object Set]
    id: 58041

위와 같은 내용은 typescript playground > settings > AST Viewer를 누르면 확인해볼 수 있다.

내용을 잘 살펴보면, 앞서 scanner 가 만들었던 토큰을 기준으로 다음과 같은 ast 트리를 만들어 낸 것을 알 수 있다.

  • VariableStatement: const를 시작으로 한 변수 선언 구문을 의미한다.
  • VariableDeclarationList: 여기에서 선언된 변수 배열을 나타낸다.

왜 배열이냐하면, let a, b, c = 3 와 같이 여러변수를 한구문에서 선언할 수있기 때문이다.

  • VariableDeclaration: message 선언부를 의미한다.
  • Identifier: message
  • StringKeyword: string 타입 선언부
  • StringLiteral: Hello, world!'

이러한 과정을 거쳐, parser는 scanner가 만들어준 token을 기준으로 신택스 트리를 만들게 된다.

parser에서는, 다음과 같은 내용을 분석하여 에러가 있는지 살펴보고 있다면 에러를 던진다.

#var = 123 // The left-hand side of an assignment expression must be a variable or a property access.(2364)
const decimal = 4.1n // A bigint literal must be an integer.(1353)
var extends = 123 // 'extends' is not allowed as a variable declaration name.(1389)
var x = { class C4 {} } // ':' expected.(1005)

parser가 분석하는 내용은 일반적으로 자바스크립트 구문이 올바른 위치에 있는지 여부를 확인한다고 보면 된다.

타입 검사

앞선 과정은 자바스크립트 컴파일러에도 존재하는 과정이었다면, 타입스크립트만의 특별한 과정인 타입검사가 다음으로 존재한다.

binder

https://github.com/microsoft/TypeScript/blob/main/src/compiler/binder.ts

바인더는 전체 파일(전체 신택스 트리)를 읽어서 타입 검사에 필요한 데이터를 수집하는 과정이라고 볼 수 있다. 전체를 읽어 드린다는 말에서 느낌이 오는 것 처럼, 이 과정은 꽤나 무거운 작업으로 볼 수 있다. 이 과정을 통해서 메타데이터를 수집하고, 타입분석에 필요한 계층 구조등을 만든다.

const message: string = 'Hello, world!'
welcome(message)

function welcome(str: string) {
  console.log(str)
}

위 파일을 다시 살펴보면 크게 global scope와 function scope 두가지로 나눠져 있는 것을 볼 수 있다.

  • global scope
    • message
    • welcome
  • function scope (welcome)
    • str

바인더는 이를 순회하면서 어디에, 그리고 어떤 identifier가 있는지 확인한다. 자세한 과정을 아래를 통해서 확인해보자.

  • message가 그 첫번째 identifier로, 0번째 식별자로 설정해두고, const이기 때문에 BlockScopedVariable로 기억해둔다.
  • 그 다음엔 welcome을 찾을 수 있다. 그러나 아직 이 식별자는 선언되지 않았으므로, 지나간다.
  • 인수로 선언되어 있는 message도 현재는 그 쓰임새를 알 수 없으므로 지나간다.
  • welcome을 드디어 찾았다. 이제 welcome을 만날 때 마다 무엇을 실행해야하는지 알 수 있게 되었다. 그리고 welcomeFunction으로 기억해둦다.
    • welcome은 함수 스코프로, parent인 global scope를 등록한다.
    • str은 함수의 인수로, BlockScopeVariable 로 등록한다.

이렇게 등록해둔 내용, 이른바 symbol은 향후 스코프에서 이 identifier를 만날때 무엇인지 판단할 때 쓸 수 있는 테이블에 등록한다.

symbol

또 한가지 binder에서 알아두어야 할 것은 flw nodes라는 개념이다.

// string, number
function log(x: string | number) {
  // string number
  if (typeof x === 'string') {
    // string
    // (1)
    return x
  } else {
    // number
    return x + 1
  }
  // 사실 여기는 unreachable
  // string number
  return x
}

위 코드를 보면, x라고 하는 변수의 타입이 각각 무엇이 될 수 있는지 머릿속에 흐름을 그려볼 수 있을 것이다. 타입스크립트에서 이러한 기능이 가능한 것은, 기본적으로 타입스크립트는 이러한 타입의 흐름을 추적하고 있기 때문인데, 이에 추가로 타입스크립트는 앞서 언급했던 스코프 내에서의 변수의 타입 변화도 추적한다.

typeof x === 'string'과 같은 구문을 flow condition, 그리고 이러한 플로우를 추적하는 스코프를 flow container라고 한다. flow condition을 기점으로 두개의 flow container가 두개 생긴것을 알 수 있다. 그리고 이러한 컨테이너 내부에서 해당 노드 (변수, identifier)가 어떤 타입인지 기억한다. 이를 바탕으로 코드가 내부에서 어떤 흐름으로 작동하는지 판단할 수 있게 된다.

이러한 flow는 타입스크립트가 해당 변수가 어떤 타입인지 추적할 수 있게 도와주는데, 이러한 추적은 밑에서 위로 올라가는 방식으로 진행된다. (1)에서 시작한다고 가정해보자. 해당 위치에서, 타입스크립트는 x의 타입이 무엇인지 flow node를 통해 물어보게되고, 가장 먼저 만나는 flow condition을 통해 xstring임을 알게 된다. 이처럼, 해당 변수의 타입을 알기 위해서 flow container 내부에서 flow condition을 만나는지, 혹은 flow container의 시작지점에서 어떻게 선언되어있는지를 확인하는 bottom-to-top 방식으로 확인한다.

바인더도 여타 다른 과정과 마찬가지로 코드를 검사하는 과정을 거친다. binder에서 확인할 수 있는 것들은 다음과 같다.

const a = 123
delete a // 'delete' cannot be called on an identifier in strict mode.(1102)

const abc = 123
const abc = 123 // Cannot redeclare block-scoped variable 'abc'.(2451)

yield // Identifier expected. 'yield' is a reserved word in strict mode.

class A {} // duplicate identifier 'A;
type A {}

이처럼 바인더는 전체를 읽어 드리는 과정에서 전체적인 context를 이해하였으므로, 이러한 전체 신택스 트리를 기준으로 잡아낼 수 있는 문제점을 지적할 수 있다. 예를 들어 strict mode에 대한 검사나, 스코프 내에서 중복된 identifier 등을 잡아낼 수 있다.

checker

https://github.com/microsoft/TypeScript/blob/main/src/compiler/checker.ts 는 이름에서도 알 수 있는 것 처럼 실제 타입을 체크하는 파일이다. 타입스크립트의 꽃이라고 볼 수 있으며, github의 file을 보면 알 수 있지만 2.67mb의 위용을 자랑한다. 대략 42000줄의 코드가 포함되어 있으며, 여기에 우리가 상상할 수 있는 흥미로운 것들이 많이 존재한다. (왜 unknownany보다 나은지, 타입스크립트의 구조적 타이핑은 무엇인지 등등..)

이렇게 하나의 파일에 크게 모두 담아 둔 이유는, 파일을 나눠서 관리하는 것 보다 하나의 파일에서 관리하는 것이 속도 측면에서 훨씬 좋기 때문이다. 특히 checker의 경우, 기존에는 100개가 넘는 import 가 존재하였는데, 이것이 속도에 있어 많은 걸림돌이 되었다고 한다.

참고자료

여기에 있는 내용을 모두 다 다루기 위해서는, 코드의 길이만큼의 설명이 필요하기 때문에 개괄적인 내용에 대해서만 다루고자한다.

checker 라는 이름에서 알수 있듯이, 대다수의 타입스크립트 validation이 여기에서 이루어진다..

ts-diagnostics

https://orta.keybase.pub/talks/tsconf-2021/long-tsconf-2021.pdf?dl=1

여기에서 중점적으로 다루고자 하는 것은, 어떻게 체크를 하는지, 그리고 어떻게 타입을 비교하는지, 그리고 추론 시스템은 어떻게 구성되어 있는 지 등 총 3가지에 대해 이야기 해보고자 한다.

const message: string = 'Hello, world'

거의 대부분의 신택스 트리에는, 이에 맞는 checker 함수가 있다고 보면 된다. 타입스크립트는 이 신택스 트리를 순회하면서 대부분의 객체들을 체크 하게 된다.

  • VariableStatement
    • VariableDeclarationList
    • VariableDeclaration
      • Identifier
      • StringKeyword
      • StringLiteral
checker.checkSourceElementWorker
checker.checkVariableStatement
checker.checkGrammarVariableDeclarationList
checker.checkVariableDeclaration
checker.checkVariableLikeDeclaration
checker.checkTypeAssignableToAndOptionallyElaborate
checker.isTypeRelatedTo

이렇듯 신택스 트리를 순차적으로 순회하면서, 체크 가능한 모든 것들을 체크하면서 validation을 진행하게 된다.

앞서, string = "Hello, world" 구문은, 다음의 과정을 거치게 된다. (checker.isTypeRelatedTo)

function isSimpleTypeRelatedTo(
  source: Type,
  target: Type,
  relation: ESMap<string, RelationComparisonResult>,
  errorReporter?: ErrorReporter,
) {
  const s = source.flags
  const t = target.flags
  if (
    t & TypeFlags.AnyOrUnknown ||
    s & TypeFlags.Never ||
    source === wildcardType
  )
    return true
  if (t & TypeFlags.Never) return false
  if (s & TypeFlags.StringLike && t & TypeFlags.String) return true
  if (
    s & TypeFlags.StringLiteral &&
    s & TypeFlags.EnumLiteral &&
    t & TypeFlags.StringLiteral &&
    !(t & TypeFlags.EnumLiteral) &&
    (source as StringLiteralType).value === (target as StringLiteralType).value
  )
    return true
  if (s & TypeFlags.NumberLike && t & TypeFlags.Number) return true
  if (
    s & TypeFlags.NumberLiteral &&
    s & TypeFlags.EnumLiteral &&
    t & TypeFlags.NumberLiteral &&
    !(t & TypeFlags.EnumLiteral) &&
    (source as NumberLiteralType).value === (target as NumberLiteralType).value
  )
    return true
  if (s & TypeFlags.BigIntLike && t & TypeFlags.BigInt) return true
  if (s & TypeFlags.BooleanLike && t & TypeFlags.Boolean) return true
  if (s & TypeFlags.ESSymbolLike && t & TypeFlags.ESSymbol) return true
  if (
    s & TypeFlags.Enum &&
    t & TypeFlags.Enum &&
    isEnumTypeRelatedTo(source.symbol, target.symbol, errorReporter)
  )
    return true
  if (s & TypeFlags.EnumLiteral && t & TypeFlags.EnumLiteral) {
    if (
      s & TypeFlags.Union &&
      t & TypeFlags.Union &&
      isEnumTypeRelatedTo(source.symbol, target.symbol, errorReporter)
    )
      return true
    if (
      s & TypeFlags.Literal &&
      t & TypeFlags.Literal &&
      (source as LiteralType).value === (target as LiteralType).value &&
      isEnumTypeRelatedTo(
        getParentOfSymbol(source.symbol)!,
        getParentOfSymbol(target.symbol)!,
        errorReporter,
      )
    )
      return true
  }
  // In non-strictNullChecks mode, `undefined` and `null` are assignable to anything except `never`.
  // Since unions and intersections may reduce to `never`, we exclude them here.
  if (
    s & TypeFlags.Undefined &&
    ((!strictNullChecks && !(t & TypeFlags.UnionOrIntersection)) ||
      t & (TypeFlags.Undefined | TypeFlags.Void))
  )
    return true
  if (
    s & TypeFlags.Null &&
    ((!strictNullChecks && !(t & TypeFlags.UnionOrIntersection)) ||
      t & TypeFlags.Null)
  )
    return true
  if (s & TypeFlags.Object && t & TypeFlags.NonPrimitive) return true
  if (relation === assignableRelation || relation === comparableRelation) {
    if (s & TypeFlags.Any) return true
    // Type number or any numeric literal type is assignable to any numeric enum type or any
    // numeric enum literal type. This rule exists for backwards compatibility reasons because
    // bit-flag enum types sometimes look like literal enum types with numeric literal values.
    if (
      s & (TypeFlags.Number | TypeFlags.NumberLiteral) &&
      !(s & TypeFlags.EnumLiteral) &&
      (t & TypeFlags.Enum ||
        (relation === assignableRelation &&
          t & TypeFlags.NumberLiteral &&
          t & TypeFlags.EnumLiteral))
    )
      return true
  }
  return false
}

https://raw.githubusercontent.com/microsoft/TypeScript/main/src/compiler/checker.ts

먼저 타입 stringstring literal즉, 값의 string을 비교하여 확인하게 되는데, 이 둘이 일치하면 true를 리턴하게 된다. 코드의 길이는 길지만, 이 경우에는 비교가 비교적 간단하여 함수 전체를 실행하지는 않을 것이다.

만약 아래와 같은 코드는 어떻게 될까?

{ hello: number} = {hello: "world"}

타입스크립트는 구조적 타이핑 (structural typing)을 기반으로 하고 있으므로 먼저 외적인 구조 부터 비교를 시작하여 안으로 파고 들게 된다. 이 경우 둘다 object의 형태를 띄고 있기 때문에 이 시점의 비교에서는 true가 리턴될 것이다. 그리고 그 다음 내부의 필드를 비교하는데, 두개 모두 hello를 가지고 있으므로 여기서도 true가 될 것이다. 그 다음 필드의 값을 비교하게 되는데, number (위 코드 기준 TypeFlags.NumberLike), 그리고 string literal"world"가 들어가 있으므로 결국에는 false가 리턴될 것이다.

이와 거의 유사한 방식으로 type generic도 비교하게 된다.

Promise<string> = Promise<{ hello: string }>

물론, 제네릭의 공변성과 반공변성에 대해 다루기 시작하면 복잡해진다.

Promise, Generic (<string>, {hello: string})

그 다음으로는 타입 추론에 대해서 알아보자. 코드의 타입 추론도 마찬가지로 checker의 역할 중 하나다.

const message: string = 'Hello, world!'

처럼 쓸 수도 있지만, 대부분의 경우에는

const message = 'Hello, world!'

를 더 선호할 것이다.

위 와 같은 경우에는, 아래와 같은 신택스 트리가 생성된다.

  • VariableStatement
    • VariableDeclarationList
      • VariableDeclaration
        • StringKeyword (name)
        • 타입이 없다! 🤔
        • StringLiteral (initializer)

이 경우에는 간단하게 initializer의 타입을 비어 있는 타입쪽으로 이동 시키면 된다.

declare function setup<T>(config: { initial(): T }): T

위와 같은 타입 파라미터 추론의 경우에는 조금 복잡해진다. 여기에서 선언된 T는 실제 함수가 사용되기 전까지 무슨 타입이 올지 알 수 없다. 여기서 checker가 얻을 수 있는 정보는 다음과 같다.

  • Generic Function
  • Generic Arg T
  • Return Value T
  • parameter는 객체이며 initial이 키이고, 값은 T

그리고 함수의 사용이 다음과 같을 때, 위와 마찬가지 프로세스로 비교하게 된다.

const abc = setup({
  initial() {
    return 'abc'
  },
})
// 객체를 시작으로 밖에서부터 비교
// T를 만날때까지 안으로 파고 든다.
// T는 string으로 추론할 수 있다.
{ initial(): T } = { initial(): string }

T를 만나서 string이라는 것이 확인 되는 순간, cheker는 해당 함수setup으로 새로운 인스턴스를 만들게 된다. 이 인스턴스에는 Tstring이라는 정보가 담기게 되고, 모든 Tstring으로 변경한다. 그리고 그 다음에 다시 checker는 비교를 시작하게 된다.

const abc = setup({initial() { return "abc" }})
{initial(): string} = {initial():"abc"}

마지막으로 알아볼 것은 contextual typing, 즉 문맥상의 타이핑이다. 문맥상의 타이핑이란, 타입의 결정이 코드의 위치 (문맥) 을 기준으로 일어난다는 것을 의미한다.

type Adder = {
  inc(n: number): number
}

const adder: Adder = {
  inc(n) {
    return n + 1
  },
}

여기에서도 마찬가지로, parameter 에서 시작하여 신택스 트리를 분석 하여 비교한다. 가장먼저 n의 경우에는 타입이 현재 존재하지 않는다. 때문에 Adder 타입으로 돌아가 타입 비교를 시작하게 된다. 이 과정은 앞서 이야기한 타입 비교 과정과 동일하다. Adder는 객체타입으로 비교 확인이 완료되고, 그 이후에 inc라는 attribute가 있는지 확인하고, 일치하였으므로, 내부의 n을 찾아 number라는 타입을 얻을 수 있게 된다.

즉 타입을 알아내기 위해 파라미터에서 시작을 했다가, 파라미터에서 타입을 알아내지 못했다면 이에 해당하는 타입으로 돌아가 n에 해당하는 타입을 가져오게 된다.

만약 addernnumber로 선언되어 있다 하더라도, Adder의 객체 타입과 비교해야 하기 때문에 동일한 과정을 또 거쳤을 것이다.

결론적으로, 타입스크립트는 이에 타입을 알아야하는 무언가가 있다면 이에 매칭하는 타입을 찾을 때 까지 신택스 트리를 거슬러 올라갈 것이다.

이렇듯 checker에는 타입을 체크하기 위핸 다양한 방법과 아이디어가 담겨 있다. 이를 근본적으로 이해하는 가장 좋은 방법은, github에서는 큰 파일이라 볼 수 없으므로, 직접 로컬에서 클론해서 checker.ts의 구조를 파악해보는 것이다. 그리고 여기에서 흥미로운 부분이 있다면, 직접 console.log를 넣어 디버깅 해보거나, 혹은 debugger를 넣는 방법 등으로 일련의 동작을 파악해본다면 도움이 될 것이다.

파일 생성하기

checker까지 거쳤다면, 이제 .js, 자바스크립트 파일을 만들 시간이다. 각종 도구들로 만들고 분석한 신택스 트리로, 어떻게 자바스크립트 파일을 만드는지 살펴보자.

https://github.com/microsoft/TypeScript/blob/main/src/compiler/emitter.ts

emitter의 역할은 신택스 트리를 읽어서 파일로 리턴하는 것이다. emitter의 다음 네가지로 크게 분류할 수 있다.

  • .js .map .d.ts를 만들어냄
  • 신택스 트리를 text로 변환
  • 루프 내부 _i _i2 와 같은 임시 변수를 추적
  • 신택스 트리를 신택스 트리로 변환 (aka transformer)

신택스 트리를 신택스 트리로 변환한다는 것은 무엇일까? 아래 예제를 보면 명확히 이해할 수 있다.

const message: string = 'Hello, world'

타입스크립트의 신택스 트리

  • VariableStatement
    • VariableDeclarationList
      • VariableDeclaration
        • identifier (name)
        • StringKeyword (type)
        • StringLiteral (initializer)

자바스크립트의 신택스 트리

  • VariableStatement
    • VariableDeclarationList
      • VariableDeclaration
        • identifier (name)
        • StringLiteral (initializer)

타입스크립트는 자바스크립트의 신택스트리와 다르게 type 이라는 것이 존재하므로, 이를 제거하는 과정을 거치게 된다.

이외에도 타입스크립트 transformer는 설정에 따라 다양한 일들을 하는데, 이는 모두 신택스트리를 기준으로 이루어진다.

  • ts syntax 제거
  • 클래스 필드 (타입스크립트에만 있는)
  • ESNext transform
  • ES2020 ~ ES2015 transform
  • ES2015 Generator
  • Module Transformer
  • ES Transformer
  • Done!

이러한 과정도, ts playground에서 plugin을 추가하여 확인해 볼 수 있다.

ts-transformer

위 코드는 최신문법이 없기 때문에 굉장히 간단하지만, 최신 코드 (ES2020 등) 를 사용해보면 transforming 하는 모습을 직접 볼 수 있을 것이다.

여기에 덧붙여, transformer이 어떤 코드를 실제로 transform을 해야하는지 알기 위해, transformertreefacts를 사용하여 이 코드가 어떤 문법으로 이루어져있는지 확인한다. 즉, 각 파트가 나중에 어떤 식으로 변경되어야 하는지 이해하는 과정을 거친다.

ts-treefacts

첫번째 코드 const의 경우 ES2015의 문법이 들어가 있기 때문에, AssetES2015라는 플래그를 기록해둔다. 그리고 이후 transformer에서 ES2015 과정을 거치게 되면 해당 플래그 부분을 변환하게 될 것이다.

welcome(msg)는 es3에서도 실행될 수 있는 무난한 자바스크립트 코드(?) 이므로 별다른 체크를 해두지 않는다.

마지막은 특별한 부분은 없지만, typescript 향 코드 이므로 (타입이 있으므로) 타입스크립트 코드라는 체크만 해둔다.

treefacts는 이처럼 각 transformer가 거쳐야 할 일들을 체크해 두며, 각 transform과정을 거칠 때 마다 하나씩 제거해서 우리가 원하는 js 파일로 만들어준다.

이와 반대로, d.ts의 경우에는 타입만을 남겨두길 원하기 때문에, 앞서 언급했던 트리에서 initializer를 제거하고 타입만 남겨 둘 것이다.

그러나 반대로 자바스크립트를 기준으로 .d.ts를 만드는 경우도 있을 것이다. (레거시 js 라이브러리를 사용하기 위해서 custom d.ts를 추가하거나 @types/***를 만드는 경우) 이 경우에는 앞서 이야기 했던 과정이 반대로 이루어진다고 생각하면 된다. 자바스크립트 신택스 트리를 만든 다음, checker를 통해서 필요한 타입을 추론하고, 이를 DTS Transformer를 거쳐 d.ts 신택스를 만들게 된다. 한가지 더 번거로운 것은, 당연하게도 자바스크립트는 타입이 없기 때문에 가능한 타입에 대해서 모든 것들을 체크해서 확인한다는 것이다.

마무리

타입스크립트 컴파일러는 앞서 살펴보았듯 여러개의 파일, 그리고 각 파일 마다 수천 수만 라인의 코드로 이루어져 있고, 이를 몇 백줄의 글로 요약하기란 불가능에 가깝다. 이글은 어디까지나 코드나 강의자료를 참고 하면서 요약해둔 내용일 뿐이다.

실제 타입스크립트 컴파일러 내부에서는 여기에서 언급한 내용 이외에도 수많은 동작과 또 우리가 알지 못한 다양한 최적화 기법이 적용되어 있다. 이를 확인해 보기 위해 직접 저장소를 방문해 코드를 보는 것을 추천한다.

회고

  • 신기 혹은 당연한 일이지만, 모든 타입스크립트 컴파일러들은 타입스크립트로 작성되어 있다.
  • 코드를 보고 나니 SWC가 왜 만들어진지 알 수 있었다. 신택스 트리르 만들고, 분석하고, 파일을 순회해서 분석하는 일은 상당히 복잡하고 많은 시간이 소요되는 일이다. 자바스크립트 개발자로서 자바스크립트를 디스하고 싶지 않지만, 확실히 이런 일은 '안' 자바스크립트가 어울릴 수 있겠다는 생각이 든다.
    • 굳이 wasm때문이 아니더라도, 자바스크립트 생태계에 rust가 조금씩 들어오는 건 어쩌면 필연적인 일이었을 수도?
  • 타입스크립트를 자주 쓰지만, 컴파일러가 어떻게 동작하는 지를 탐구해볼 생각을 많이 안해본 것 같다. 이번 기회에 많이 배우게 되었다.