
오랜만에 작성해보는 타입스크립트 포스트입니다. 이번에 이야기해볼 주제는 TypeSciprt의 Type Narrowing 동작입니다. TypeScript에서는 Type Narrowing을 어떻게 설명하고 있는지에 대해 공식 문서를 통해 함께 공부해봅시다.
TypeSciprt Docs에서는 TypeScript가 여러 타입의 값이 대입될 수 있는 변수에 대해, 어떻게 각 타입별로 동작을 다르게 코드를 작성하는지에 대한 가이드로, 📖 narrowing이라는 파트가 있습니다.
문서에서는 TypeScript의 Type Narrowing 동작에 대한 소개와 어떻게 적절히 이를 사용할 수 있는 지를 다양하게 설명해주고 있습니다. 해당 문서를 간단히 요약해서 훑어보도록 합시다.
Type Narrowing이란 무엇인가요?padLeft라는 함수를 만들어봅시다. padding parameter의 값이 number라면 그 값만큼의 공백을 input의 왼쪽에 추가해서 반환하고, string이라면 그대로 input의 왼쪽에 추가해서 반환하는 동작을 해야 합니다.
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
우리는 자연스럽게 typeof 키워드를 사용하여 위와 같이 구현하게 될 것입니다. 이 과정에서 padding이 가질 수 있는 타입들을 동작을 달리하기 위해 분기하고, 좁히는 구문들을 작성하게 됩니다. 여기서 타입을 좁히는 과정을 우리는 type narrowing이라 부릅니다.
TypeScript는 런타임에서만 이에 대한 동작을 보장하는 것이 아니라, 컴파일 이전 단계에서도 저희가 직접적으로 타입 인터페이스를 수정해주지 않아도 문법적으로 narrowing된 타입들에 대해 명시적으로 구분해줍니다. 단순하게 위와 같은 코드에 마우스만 올려놓아보아도, 우리는 if구문 분기를 통해 각 코드블럭 내에서 타입이 좁혀져있는 것을 확인할 수 있습니다.

TypeScript의 이러한 narrowing 동작에 대해 예측 가능성을 높히기 위해서는 어떤 구문들이 실제 타입 추론에 영향을 주는지 인지할 필요가 있습니다. 위의 코드블럭에서 확인할 수 있는 typeof구문을 시작으로 어떤 문법들이 이렇게 타입 추론에 영향을 주는지 예문들과 함께 간단히 정리해보겠습니다.
typeof Narrowingfunction padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
위에서 살펴봤던 것처럼, JavaScript의 typeof 구문은 TypeScript의 타입 추론을 트리거링합니다.
function getUsersOnlineMessage(numUsersOnline: null | number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
boolean값 대신에 변수를 입력할 경우, 그 값이 0, NaN, "", 0n, null, undefined일 때 false를, 아닐 때 true를 반환하도록 하는 JavaScript의 문법적 특징 중 하나인 Truthiness도 TypeScript의 타입 추론에 영향을 줍니다.

function example(x: string | number, y: string | boolean) {
if (x === y) {
console.log(x.toUpperCase());
}
}
===, !==, ==, != 연산자를 이용하여 두 값을 비교하면, TypeScript는 타입이 동일해야 한다는 점을 활용하여 타입을 좁힙니다. 위의 예시에서는 x와 y가 가질 수 있으면서 둘이 타입이 동일할 수 있는 string타입으로 타입이 추론됩니다.

in Keyword Narrowingtype Fish = { swim: () => void };
type Bird = { fly: () => void };
type Animal = Fish | Bird;
function move(animal: Animal) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}
in 연산자를 사용하면 특정 속성이 객체 내에 존재하는지 확인할 수 있으며, 이를 통해 객체의 타입을 좁힐 수 있습니다. 위의 코드에서는 swim이라는 멤버 함수를 가지냐를 통해 animal이 Fish 타입인지, Bird 타입인지 추론합니다.

instanceof Narrowingfunction logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString()); // Date 메서드 사용 가능
} else {
console.log(x.toUpperCase()); // string 메서드 사용 가능
}
}
instanceof 연산자를 사용하면 특정 객체가 클래스의 인스턴스인지 확인하고 타입을 좁힐 수 있습니다.
let x = Math.random() < 0.5 ? 10 : "hello";
x = 1; // x는 number로 좁혀짐
x = "goodbye"; // x는 string으로 좁혀짐
변수에 값을 할당할 때, TypeScript는 코드 흐름을 분석하여 타입을 좁힙니다.
function example() {
let x: string | number | boolean;
x = Math.random() < 0.5;
console.log(x); // boolean 타입
x = "hello";
console.log(x); // string 타입
x = 42;
console.log(x); // number 타입
}
제어 흐름 분석을 통해 TypeScript는 코드의 흐름을 따라가며 변수의 타입을 좁힙니다.
is keyword)function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
타입 판별자(pet is Fish)를 사용하면 TypeScript는 특정 함수가 타입을 판별한다고 인식하고, 조건문 안에서 타입을 좁힐 수 있습니다.
interface Cat {
kind:'cat';
sound:'meow';
}
const isCat = (value:unknown):value is Cat => {
return (
typeof value === "object" &&
value !== null &&
"kind" in value &&
"sound" in value &&
(value as Cat).kind === "cat"
)
}
function handleAnimal(value: unknown) {
if (isCat(value)) {
console.log(value.sound);
} else {
console.log("Not a cat");
}
}
위와 같이 unknown 타입에 대한 추론을 통해 코드를 분기하는 상황이라면, is keyword를 활용한 Type Predicate를 통해 custom type에 대한 여부와 동작을 분기처리할 수 있습니다. 위와 같은 코드에서 value is Cat이라는 구문이 없을 경우, 아래와 같이 에러가 발생합니다.

function assertIsNumber(value: any): asserts value is number {
if (typeof value !== "number") {
throw new Error("값이 숫자가 아닙니다.");
}
}
asserts 키워드를 사용하면 함수 호출 후에 변수가 특정 타입임을 TypeScript가 보장할 수 있도록 도와줍니다. 위의 함수는 값의 반환을 통해 value 파라미터가 number임을 반환하지는 않지만, number가 아닐 경우 코드 진행에 블로킹이 발생되는 Error를 던지고 있기 때문에, 본 함수 이후에 실행될 수 있는 함수들은 value가 number임을 TypeScript가 추론하도록 강제할 수 있습니다.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
}
}
구별된 유니언(kind 속성)을 사용하여 각 타입을 명확히 구별할 수 있습니다.
function error(message: string): never {
throw new Error(message);
}
never 타입은 절대 반환되지 않는 값을 의미합니다. 이는 예외를 던지거나 무한 루프일 때 사용됩니다.
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
모든 가능한 타입을 처리했는지 확인하는 기법입니다. 새로운 타입이 추가되었지만 switch 문에서 처리되지 않으면 TypeScript가 오류를 발생시킵니다. 위 코드에서는 에러가 발생하지 않지만 만약 Shape에 Triangle과 같은 타입이 추가된다면 아래와 같이 코드가 수정될 것입니다.
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
이 상황에서 Exhaustive Check 문법에서 에러를 발생시켜 처리가 필요한 모든 타입을 처리하였는지 확인을 할 수 있고, 이를 통해 타입 안정성이 높은 코드를 작성할 수 있게 됩니다.

우리는 이러한 내용을 왜 알아야 할까요?
먼저 앞서 이야기했듯이, 우리는 TypeScript를 활용해 개발하면서 코드 동작의 예측 가능성을 높히기 위해 위의 특징들을 이해하고 있어야 합니다. 우리가 이런 내용들을 살펴보는 건, 구체적으로는 특정 구문에서 초기에 선언해 준 타입과 다른 타입으로 추론되어있는 변수를 보았을 때 그 추론이 어떤 과정으로 이루어졌는지 이해하기 위함입니다.
TypeScript를 사용하면서 any를 최대한 자제해야한다는 이야기가 많습니다. 하지만 broad한 타입을 선언하거나 파라미터로 받아야 할 때는 쉽지가 않은데요. unknown과 never를 적극적으로 활용하고, unknown 타입에 대해서 원하는 동작을 하도록 만들기 위해서는 Type Narrowing에 대해 이해하고 이를 활용해야 합니다.
저도 실제 프로덕트에 코드를 작성할 때 최대한 타입 안정성을 높히고 strict한 형태로 예외 없는 코드를 작성하려고 노력하고 있습니다. 본 글의 부록으로 다음 시리즈 포스팅에서는 오늘 살펴본 Type Narrowing을 적극적으로 활용한 다양한 패턴들을 소개해볼까 합니다.
글 읽어주셔서 감사드리고, 틀린 점이나 오타 등에 대한 지적은 항상 감사드립니다. 😀