개발 공부

TSC는 Type Narrowing을 어떻게 처리하는 걸까

gamzamandu 2026. 4. 28. 17:40

[서론 : TSC에 대한 소개 그리고 Type Narrowing]

TSC는 무엇인가?

TSC는 TypeScript Complier로, TypesScript 코드를 JavaScript로 변환해 주는 컴파일러이다. TSC는 타입 검사를 통해 코드 안정성을 보장하면서도 런타임에서 실행 가능한 JS 코드를 생성한다. TSC는 tsconfig.json를 기반으로 소스 파일들의 의존성 그래프를 구축하고 전체 컴파일 콘텍스트를 관리하는 Program을 실행하게 된다.

 

TSC는 크게 소스코드를 키워드/연산자 단위로 토큰화하는 Scanner, 토큰화된 SyntaxKind를 AST(추상구문트리)로 변환하는 Parser, AST Node를 토대로 타입정보(선언과 참조)를 매핑하는 Binder, 이렇게 만들어진 Symbol Table을 토대로 타입 일치 여부를 검사하는 Type Checker, 마지막으로 JS소스와 .d.ts 파일을 생성하는 Emitter 단계로 나뉜다.

Type Narrowing는 무엇인가?

먼저 Type Narrowing에 대해 간략하게 소개한다면, TypeScript에서 타입 내로잉(Narrowing)은 유니언 타입(union type)처럼 여러 가능한 타입 중 하나로 범위를 좁혀 더 구체적인 타입으로 만드는 과정이라고 할 수 있다.

function processInput(input: string | number) {
  if (typeof input === "string") {
    // input은 이제 string 타입으로 좁혀짐
    return input.toUpperCase();
  } else {
    // input은 이제 number 타입으로 좁혀짐
    return input.toFixed(2);
  }
}

위 예시를 보면 ProcessInput의 인자인 input은 string과 number를 가질 수 있는 union type이다. typeof, in, instanceof 같은 type guard를 통해 string과 number를 구분하며 타입이 자동으로 좁혀진다. (이래서 타입 좁히기라고 부른다.)

 

본문의 요지는 이것이다. TSC에서 Type Narrowing는 어떻게 처리하는 것일까?

Type의 유효성을 검증하고, 필요한 경우 더 구체적인 타입으로 좁혀나가는(Narrowing) 작업이 수행되는 곳은 Type Checker 단계이다.

Type Checker 단계에서 타입을 검사하는 방법을 더 파보도록 하자.

[본론 : TSC는 Type Narrowing을 어떻게 처리하는 걸까]

간단한 Type Narrowing을 다루는 소스코드를 Type Checker 과정까지 전개해보겠다.

let x: string | number = 123;
if (typeof x === 'string') {
  x.toUpperCase();
}

 

1. [Scanner & Parser]

앞서 설명했듯이, 소스코드는 Scanner 단계에서 토큰 단위로 분해되고 Parser 단계에서 AST(추상구문트리)로 변환된다.

AST는 소스코드에서 세미콜론이나 괄호 같은 사소한 문법 요소는 생략해 핵심 구조만 남겨 해석을 유연하게 할 수 있게 도와준다. 

 

TypeScript의 AST로 변환한다면 다음과 같아진다.

2. [Binder]

자, 이제 Binder에서 AST의 정보를 토대로 Symbol Table를 생성해 보겠다.

AST Node를 순회하면서 VariableDeclaration(변수선언) 노드를 발견한다. 해당 노드의 Identifier("x") 식별자의 이름을 마주한다.

현재 스코프에 식별자의 이름을 키로 한 메타데이터를 Symbol Table 안에 구성하게 된다. 이 정보를 토대로 Checker가 x의 타입은 string | number였군. 이렇게 알 수 있는 것이다.

 

이제 중요한 지점이 나온다. 그럼 <Type Narrowing은 어떻게 이루어지는 것인가?>

이런 제어 흐름을 바뀌는 지점들을 저장하기 위해, 실행 경로를 분석하여 각 지점의 타입을 추론하는 Control Flow Analysis(과정을 제어 흐름 분석)이라고 한다. 이 과정 덕분에 TSC가 타입이 분기되는 지점에서도 빈틈없이 타입을 추적할 수 있는 것이다.

 

다시 TSC를 전개하러 가보자. 

Binder는 AST를 조회하다가 if문, typeof, instanceof 같은 타입의 범위를 좁히는 지점을 만나면 Flow Graph를 생성하게 된다.

이렇게 Flow Node가 구성되는 것이다.

 

좀 더 복잡적인 상황에 대한 Flow Node가 궁금하다면 참고하라.

조건문 IfStatement test 조건의 true/false 분기 생성.
조건 연산자 ConditionalExpression ? : 삼항 연산자. 분기 생성.
반복문 WhileStatement, ForStatement 루프 진입/탈출 시 타입 재계산 필요.
논리 연산자 BinaryExpression &&, || 연산자. 단락 평가(Short-circuit)로 인한 타입 내로잉.
타입 가드 BinaryExpression typeof, instanceof 비교식. Flow Node의 핵심.
분기점 SwitchStatement case 별로 타입 흐름 분기.

 

3. [Checker]

자 이제 코드의 구조를 위한 Symbol Table과 타입의 흐름을 위한 Flow Node가 구성되었다. 이것을 Checker가 해석만 하면 될 일이다.

Checker가 AST를 다시 순회하며 타입 검사를 수행할 때, 각 노드에서 다음과 같은 과정을 거친다.

1. [근본 타입 확인]
Checker는 현재 방문 중인 식별자(x)를 만났을 때, Symbol Table을 조회하여 x가 string | number로 정의되어 있다는 기본 정보를 먼저 확보한다.


2. [타입 내로잉]
Checker가 if (typeof x === 'string') 블록 안으로 들어간다. (이때 Checker는 Flow Graph를 참조한다.)
Flow Graph는 해당 블록이 typeof x === 'string' 조건이 true인 경로임을 알려준다. Checker는 이 정보를 바탕으로, Symbol Table에 있던 원래 타입(string | number)을 해당 블록 안에서만 string으로 좁힌다.

3. [타입 안전성 검증]
이제 블록 내부의 x.toUpperCase()를 만난다.
Checker는 좁혀진 타입(string)을 기준으로 .toUpperCase() 메서드가 존재하는지 검사한다.

string 타입에는 해당 메서드가 존재하므로, 타입 체커는 이를 "안전함"으로 통과시킨다. 만약 블록 밖에서 호출했다면, 여전히 string | number로 남아있어 에러를 뱉었을 것.

[결론 : Type Narrowing이 가능한 건 Control Flow Analysis 덕분]

지금까지 살펴본 것처럼, 다양한 분기 조건에서도 타입을 정확하게 추론하는 이유는 제어 흐름 분석(Control Flow Analysis, CFA) 덕분이다.

TSC는 Binder를 통해 변수의 선언과 범위를 담은 Symbol Table을 구축하고, 코드의 실행 경로마다 타입이 어떻게 변하는지 추적하는 Flow Graph를 설계하고 Type Checker가 이 두 가지를 결합한 것이다.

 

사실 이 과정을 통해 Type Narrowing 뿐만 아니라. Type Assertion이 어떻게 동작하는지도 이해할 수 있다.