타입스크립트 타입추론 알아보기

김정래·2025년 3월 30일
0
post-thumbnail

흐름을 따라 타입이 바뀐다?

타입스크립트의 타입 추론과 제어 흐름 기반 내로잉

TypeScript를 쓰다 보면 변수의 타입을 따로 지정하지 않았는데도, 조건문 하나로 타입이 좁혀지거나 달라지는 것처럼 보이는 경험을 할 수 있다. 그건 바로 타입스크립트의 추론 시스템과 제어 흐름 분석 덕분이다.

이 글에서는 타입스크립트의 기본적인 타입 추론부터
흐름에 따른 타입 내로잉, 그리고 그 아래에서 작동하는 FlowNode와 Control Flow Graph 개념까지 정리해보았다.


1. 타입스크립트의 타입 추론: 선언하지 않아도 타입 알아내기

TypeScript에서는 대부분 변수에 타입을 직접 쓰지 않아도 알아서 추론이 가능하다.

let userName = "jeongrae";
userName.toUpperCase(); // OK
userName = 33; // Error: Type 'number' is not assignable to type 'string'.

userName의 타입을 선언하지 않았지만, 문자열을 대입한 순간 TypeScript는 string 형식의 값이라고 자동으로 판단한다. 이후 number 타입을 넣으면 타입 에러가 발생한다. 이런 타입 추론이 없었다면, 매번 변수에 타입을 써야 했을 것이다.

타입을 단언하고 시작하는 Java 언어와 비교하면 더 이해가 명확하다.

String userName = "jeongrae";
userName.toUpperCase(); // OK
userName = 33; // Error: Type 'int' is not assignable to type 'String'.

여기서는 내가 userName을 String이라고 선언해주었기 때문에 String이 된 것이지, JVM이 String이라고 추론해준 것은 아니다.


2. 타입스크립트의 타입 추론 방향성: 만족하는 가장 일반적인 타입을 사용하기

타입스크립트의 추론은 언제나 "최소 요구사항을 만족하는 가장 일반적인(Common) 타입" 을 기준으로 이뤄진다.

let userAge = 20; // 추론 -> number
const userWeight = 50.0; // 추론 -> 50.0 (리터럴)

let 변수는 일반화된 타입으로 추론되고, const 변수는 값을 바꿀 수 없기 때문에 리터럴 그대로 유지된다.

자세한 내용은 공식 Handbook에서 확인이 가능하다.
TypeScript Handbook: type-inference

추론은 단순히 초기값에만 의존하지 않고 함수 호출 시 인자의 타입, 콜백의 맥락 등 “값이 사용되는 위치” 에 따라서 타입이 추론된다. 이를 문맥적 타이핑(Contextual Typing) 이라고 부른다.


3. 흐름을 따른 타입의 변화: 타입 내로잉(Narrowing)

타입스크립트의 가장 강력한 추론 기능 중 하나는 제어 흐름에 따라 타입을 좁히는 것이다.
아래 코드를 보자.

function handleUser(userName: string | undefined): string {
    if (userName) {
        return userName.toUpperCase();
    }
    return "";
}

userName의 타입은 string | undefined이다.
그런 상태에서 userName.toUpperCase()를 실행하면 undefined일 수도 있으니 오류가 발생해야 한다.
하지만 if (userName)을 통해 undefined의 가능성을 제거했기 때문에,
해당 블록 내부에서는 userNamestring으로 취급될 수 있다.

이렇듯 위치(블록)흐름(if, else, switch 등) 에 따라 타입이 달라지는 것이 바로 타입 내로잉이다.
TypeScript는 if, typeof, instanceof, in, 사용자 정의 타입 가드 is T 등 다양한 조건문을 통해
타입을 코드 흐름에 따라 동적으로 좁혀 나간다.


4. 흐름을 따라 움직이는 타입: Control Flow Graph와 FlowNode 개념

이러한 타입의 흐름 기반 추론은 컴파일러 내부에서 Control Flow Graph 를 통해 이루어진다.

  • CFG는 코드의 흐름을 노드 단위로 모델링한 그래프
  • 각 분기점(if, switch, typeof 등)마다 새로운 FlowNode가 생성됨
  • 변수의 타입 상태는 각 FlowNode에서 개별적으로 추적됨

예를 들어 다음 조건문을 살펴보자:

function handleUser(userName: string | undefined) {
    if (userName) {
        userName.toUpperCase(); // string
    }
}

컴파일러는 if (userName)을 만나면 userName이 truthy하다는 조건 아래에서 undefined를 제거하고,
그 범위에서 userName: string이라는 새로운 타입 스냅샷을 만든다.


5. 타입을 좁히는 흐름: narrowTypeByControlFlow라는 개념적 구조

TypeScript는 변수의 타입을 현재 코드 흐름(context) 에 따라 자동으로 좁히는 정교한 분석을 수행한다.
이 흐름 기반 타입 분석은 내부적으로 매우 복잡하게 구현돼 있으며,
@microsoft/TypeScript: checker.ts 에서 실제 코드를 확인할 수 있다.

우리가 흔히 "타입을 좁힌다(narrowing)" 고 말할 때, 내부적으로는 다음과 같은 로직들이 조합된다.
아래 코드는 그 개념을 설명하기 위해 작성된 의사코드다.

// TypeScript 컴파일러의 흐름 기반 타입 분석을 추상화해 표현한 의사코드
function narrowTypeByControlFlow(baseType: Type, flow: FlowNode): Type {
    while (flow) {
        switch (flow.kind) {
            case FlowCondition:
                // typeof, null 체크 등 조건문에 따른 narrowing
                baseType = narrowByCondition(baseType, flow);
                // 실제 구현 예: narrowTypeByTruthiness, narrowTypeByDiscriminant
                break;
            case FlowCall:
                // 사용자 정의 타입 가드 (val is T)에 의한 narrowing
                baseType = narrowByTypeGuard(baseType, flow);
                // 실제 구현 예: narrowTypeByTypeGuard
                break;
            case FlowAssignment:
                // 새 값이 할당된 경우, 타입을 해당 값으로 대체
                baseType = flow.assignedType;
                // 실제 구현 예: getFlowTypeOfReference 내부 처리
                break;
        }
        flow = flow.antecedent; // 이전 흐름으로 이동
    }
    return baseType;
}

예시 1: typeof 조건문을 통한 narrowing

function handleUser(userName: string | undefined) {
    if (typeof userName === "string") {
        return userName.toUpperCase(); // 여기서는 userName이 string
    }
    return "User not provided.";
}

이 코드가 실행될 때 컴파일러는 다음 과정을 거친다:

  1. userName의 타입은 string | undefined.
  2. typeof userName === 'string' 조건을 인식 → FlowCondition 노드가 생성됨.
  3. userName에서 undefined 부분이 제거되어 string으로 narrowing.
  4. userName.toUpperCase()는 안전하다고 판정됨.

이 과정은 내부적으로 getFlowTypeOfReference()narrowTypeByTruthiness() 또는 narrowTypeByDiscriminant() 등이 수행한다.


예시 2: 사용자 정의 타입 가드를 통한 narrowing

function isUserValid(val: unknown): val is string {
    return typeof val === "string";
}

function handleUser(userName: unknown) {
    if (isUserValid(userName)) {
        userName.toUpperCase(); // 여기서는 userName이 string
    }
}

여기서 TypeScript는 다음 흐름을 따른다:

  1. isUserValid(userName) 호출 → 반환 타입이 userName is string.
  2. FlowCall 노드 생성.
  3. 해당 분기에서 userNamestring으로 narrowing.
  4. userName.toUpperCase()를 안전하다고 판단.

이때 활용되는 내부 함수는 narrowTypeByTypeGuard()다.


이처럼 조건문 안에서 타입이 자동으로 바뀌는 이유는,
"그 시점에서 흐름(Flow)에 따른 타입 내로잉이 적용되었기 때문" 이라는 관점에서 이해할 수 있다.

그리고 이는 TypeScript가 단순히 타입만 보는 것이 아니라,
코드의 실행 흐름과 가능성(분기)까지 분석하는 정적 타입 시스템임을 보여주는 좋은 예시이기도 하다.

profile
https://github.com/Jeong-Rae/Jeong-Rae

0개의 댓글