TypeScript는 코드를 읽는다 — 제어 흐름 분석
CCTV 관제센터를 상상해봅시다. 모니터 요원은 카메라 여러 대를 동시에 지켜봅니다. "1번 카메라에서 문이 열렸다"는 사실을 파악하는 순간, "이 방에는 지금 누군가가 있다"고 결론 내립니다. 다음 카메라로 시선을 옮기면 그 사람이 책상 앞에 앉았다는 것을 알게 됩니다. 관제 요원은 모든 순간의 맥락을 기억하며 상황을 추적합니다.
TypeScript 컴파일러도 똑같습니다. 코드를 위에서 아래로 읽으면서, 각 시점에서 변수가 어떤 타입일 수 있는지를 추적합니다. if 문을 만나면 조건을 분석하고, 그 블록 안에서 타입이 어떻게 좁아졌는지 파악합니다. 이 능력을 제어 흐름 분석(Control Flow Analysis)이라고 합니다.
유니온 타입과 좁히기의 필요성
PART 03에서 배운 유니온 타입을 떠올려봅시다. string | number는 "문자열이거나 숫자"입니다. 그런데 이 값을 받아서 실제로 뭔가를 하려면 문제가 생깁니다.
// 새 파일: narrowing-intro.tsfunction formatValue(value: string | number): string { return value.toUpperCase(); // 오류: 'number' 형식에는 'toUpperCase' 속성이 없습니다.}
TypeScript가 오류를 냅니다. value가 숫자일 수도 있는데 문자열 메서드를 쓰려 했기 때문입니다. value.toFixed(2)를 써도 마찬가지입니다. 숫자 메서드를 문자열에는 쓸 수 없습니다.
두 타입 모두에 존재하는 메서드(toString() 등)만 안전하게 쓸 수 있습니다. 하지만 우리는 타입에 따라 다른 처리를 하고 싶습니다. 여기서 좁히기가 필요합니다.
조건문으로 타입이 좁아진다
if 문을 써서 타입을 확인하면, 그 블록 안에서 TypeScript는 타입을 좁혀서 인식합니다.
// 새 파일: simple-narrowing.tsfunction formatValue(value: string | number): string { if (typeof value === "string") { // 이 블록 안에서 value는 string입니다 return value.toUpperCase(); // 안전하게 사용 가능 } else { // 이 블록 안에서 value는 number입니다 return value.toFixed(2); // 안전하게 사용 가능 }}console.log(formatValue("hello")); // HELLOconsole.log(formatValue(3.14159)); // 3.14
if (typeof value === "string") 조건이 참인 블록 안에서는 TypeScript가 value를 string으로 확정합니다. else 블록에서는 string이 아닌 경우만 남으므로 number로 확정합니다. 이것이 좁히기입니다.
코드 경로를 따라 타입이 달라진다
TypeScript는 단순히 블록을 구분하는 것을 넘어, 코드의 모든 경로를 따라갑니다.
// 새 파일: flow-analysis.tsfunction processInput(input: string | null): string { // 여기서 input의 타입은 string | null입니다 console.log(input); // input: string | null if (input === null) { return "값이 없습니다."; } // 이 줄에 도달하면 input은 null이 아닙니다 // TypeScript는 이것을 압니다: input의 타입은 string입니다 return input.toUpperCase(); // 안전합니다}console.log(processInput("hello")); // HELLOconsole.log(processInput(null)); // 값이 없습니다.
if (input === null) { return ...; } 이후에는 null인 경우가 이미 함수를 빠져나갔습니다. 그래서 그 아래 코드에서 TypeScript는 input이 string임을 압니다. 이것이 제어 흐름 분석의 핵심입니다.
이른 반환(early return)을 활용하면 중첩된 if 없이 코드를 깔끔하게 작성할 수 있습니다.
null과 undefined 체크
null과 undefined를 처리하는 패턴은 실무에서 매우 자주 쓰입니다.
// 새 파일: null-check.tsinterface User { name: string; nickname?: string; // 선택적 속성 — string | undefined}function greetUser(user: User): string { if (user.nickname !== undefined) { // 이 블록 안에서 user.nickname은 string입니다 return `안녕하세요, ${user.nickname}님!`; } return `안녕하세요, ${user.name}님!`;}console.log(greetUser({ name: "이민준", nickname: "민준쌤" })); // 안녕하세요, 민준쌤님!console.log(greetUser({ name: "박지수" })); // 안녕하세요, 박지수님!
더 간결한 방법도 있습니다. if (user.nickname) 처럼 truthy 검사를 해도 됩니다. 다만 빈 문자열("")도 falsy이므로 주의가 필요합니다. 빈 문자열도 유효한 값이라면 !== undefined나 !== null로 명확히 검사해야 합니다.
// truthy 검사 — 빈 문자열은 falsy로 취급됩니다function getDisplayName(name: string | null | undefined): string { if (name) { return name; // string (빈 문자열 제외) } return "이름 없음";}// null 병합 연산자를 활용한 더 간결한 방법function getDisplayName2(name: string | null | undefined): string { return name ?? "이름 없음"; // null 또는 undefined이면 기본값 사용}
?? 연산자는 null 또는 undefined일 때만 오른쪽 값을 사용합니다. ||와 달리 빈 문자열은 그대로 통과시킵니다.
타입 좁히기는 블록 안에서만 유효하다
좁혀진 타입은 해당 블록 안에서만 유효합니다. 블록을 벗어나면 원래 타입으로 돌아갑니다.
// 새 파일: narrowing-scope.tsfunction showLength(value: string | number): void { let length: number; if (typeof value === "string") { length = value.length; // value는 string — 안전합니다 } else { length = value.toString().length; // value는 number } // 블록을 벗어나면 value는 다시 string | number입니다 console.log(`값: ${value}, 길이: ${length}`);}showLength("hello"); // 값: hello, 길이: 5showLength(12345); // 값: 12345, 길이: 5
블록 안에서 좁혀진 타입 덕분에 각각의 처리를 안전하게 했고, 결과를 length 변수에 담아 블록 밖에서 사용했습니다.
할당으로도 타입이 좁아진다
값을 직접 할당하면 그것도 좁히기입니다. TypeScript는 할당된 값을 보고 타입을 확정합니다.
// 새 파일: assignment-narrowing.tslet value: string | number;value = "안녕하세요";// 여기서 value의 타입은 string입니다console.log(value.toUpperCase()); // 안전합니다value = 42;// 여기서 value의 타입은 number입니다console.log(value.toFixed(2)); // 안전합니다
TypeScript는 변수에 어떤 값이 마지막으로 할당됐는지 추적합니다. 코드 어느 시점에서든 그 변수가 어떤 타입인지 알고 있습니다.
여러 조건을 함께 검사하기
&&와 || 연산자도 타입 좁히기에 영향을 줍니다.
// 새 파일: complex-narrowing.tsinterface Config { host?: string; port?: number;}function buildUrl(config: Config): string { const host = config.host ?? "localhost"; const port = config.port ?? 3000; return `http://${host}:${port}`;}function validateConfig(config: Config): string { if (!config.host || !config.port) { // host 또는 port가 없거나 falsy입니다 return "설정이 불완전합니다."; } // 이 줄에 도달하면 host와 port 둘 다 truthy 값입니다 // TypeScript는 config.host와 config.port가 각각 string, number임을 압니다 return `연결: ${config.host}:${config.port}`;}console.log(buildUrl({ host: "example.com", port: 8080 }));// http://example.com:8080console.log(validateConfig({ host: "example.com" }));// 설정이 불완전합니다.console.log(validateConfig({ host: "example.com", port: 8080 }));// 연결: example.com:8080
타입 좁히기는 자동으로 작동한다
여기서 중요한 점은, 이 모든 것이 별도의 선언이나 설정 없이 자동으로 이루어진다는 것입니다. TypeScript 컴파일러가 코드를 읽으며 스스로 타입을 추적합니다. 개발자는 자연스럽게 if로 조건을 검사하기만 하면 됩니다.
이것이 TypeScript가 단순한 타입 주석 도구가 아닌 이유입니다. 코드의 의미를 이해하고, 실행 경로를 따라 타입이 어떻게 변하는지 추론합니다.
TypeScript는 CCTV 모니터 요원처럼 코드의 모든 순간을 지켜봅니다. if 문 하나가 관제 요원의 메모처럼 작용합니다. "이 블록에 들어왔다는 것은 이 조건이 참이라는 뜻이다. 그러면 이 변수는 이런 타입이다."
다음 장에서는 이 좁히기 도구를 더 구체적으로 살펴봅니다. 기본형을 검사하는 typeof, 클래스 인스턴스를 검사하는 instanceof, 그리고 공통 필드로 분기하는 판별 유니온 패턴을 배웁니다.