본문 바로가기

타입 가드와 타입 좁히기

typeof와 instanceof, in, 동등성과 참거짓 검사로 유니온 값의 타입을 좁히는 방법을 정리하고, 구조가 같은 객체에서 instanceof가 어긋나는 경우와 사용자 정의 타입 가드, 단언 함수, 추론되는 타입 술어를 예제로 다룹니다.

유니온 타입의 값은 좁히기 전에는 모든 후보에 공통인 연산만 쓸 수 있다는 것을 2장에서 보았습니다. 그 뒤로도 typeof와 in 같은 검사로 타입을 좁히는 코드를 여러 번 썼습니다. 이 글은 그 검사들을 한자리에 모아 각각이 무엇을 확인하는지 정리합니다.

조건문 안에서 값의 타입을 더 구체적인 타입으로 줄이는 것을 타입 좁히기(narrowing)라 하고, 그 조건에 쓰인 검사를 타입 가드(type guard)라고 합니다. 이 글은 기본으로 제공되는 타입 가드를 차례로 보고, 구조가 같은 객체에서 instanceof가 예상과 다르게 동작하는 경우를 확인한 뒤, 직접 타입 가드 함수를 만드는 방법을 다룹니다. 예제는 ts-first 프로젝트에 narrowing.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

타입 좁히기

컴파일러는 코드의 흐름을 따라가며, 조건문의 검사가 참인 분기와 거짓인 분기에서 값의 타입을 각각 다르게 봅니다. 이것을 제어 흐름 분석(control flow analysis)이라고 하며, 같은 변수라도 코드의 위치에 따라 타입이 달라집니다.

function printId(id: string | number) {
  console.log(`ID: ${id}`);
  if (typeof id === "string") {
    console.log(`대문자 ID: ${id.toUpperCase()}`);
  } else {
    console.log(`고정 소수점 ID: ${id.toFixed(2)}`);
  }
}

printId("abc123DEF");
printId(123.456);
ID: abc123DEF
대문자 ID: ABC123DEF
ID: 123.456
고정 소수점 ID: 123.46

함수의 첫 줄에서 id는 string | number이지만, typeof id === "string"이 참인 분기에서는 string으로, else 분기에서는 남은 number로 좁혀집니다. 두 분기는 둘 가운데 하나만 실행되며, 123.456.toFixed(2)는 소수 셋째 자리에서 반올림해 123.46이 됩니다.

typeof는 문자열과 숫자를, instanceof는 클래스 인스턴스를, in은 속성의 존재를 확인한다. 사용자 정의 가드의 반환 타입은 구현과 일치해야 한다.

TYPE / RUNTIME / CONTRACT

같은 입력도 검사한 분기에서 다른 타입이 된다

타입 가드는 실행 중 확인한 사실을 이용해 현재 분기의 타입을 좁힙니다. 각 검사와 사용할 수 있는 연산을 함께 읽으세요.

printId는 확인한 타입에서 다른 연산을 선택한다입력string|number→typeof검사→문자열의toUpperCase또는숫자의toFixed두분기이다. 문자열참분기와숫자거짓분기는택일이며순차실행이아니다. prototype검사등다른가드는아래비교설명이며이그림의분기노드가아니다.printId 입력string | numbertypeof id=== "string" ?참 · 문자열toUpperCase()abc123DEF → ABC123DEF거짓 · 숫자toFixed(2)123.456 → 123.46참거짓
printId는 확인한 타입에서 다른 연산을 선택한다 · 모바일입력string|number→typeof검사→문자열의toUpperCase또는숫자의toFixed두분기이다. 문자열참분기와숫자거짓분기는택일이며순차실행이아니다. prototype검사등다른가드는아래비교설명이며이그림의분기노드가아니다. 모바일에서도 동일 노드와 간선 방향을 유지한다.printId 입력string | numbertypeof id=== "string" ?참 · 문자열toUpperCase()abc123DEF → ABC123DEF거짓 · 숫자toFixed(2)123.456 → 123.46참거짓 · 바깥 가지

TYPEOF

문자열과 숫자

printId의 문자열 분기는 toUpperCase(), 숫자 분기는 toFixed(2)를 사용합니다. abc123DEF → ABC123DEF, 123.456 → 123.46입니다.

INSTANCEOF / IN

인스턴스와 속성

Bird는 fly(), Fish는 swim()을 호출합니다. Car | Boat는 'drive' in vehicle을 검사해 참이면 drive(), 아니면 sail()을 호출합니다. 검사마다 증명하는 사실이 다릅니다.

TRUTHINESS

값이 있는지 확인

greetUser의 email 검사는 undefined와 null뿐 아니라 빈 문자열도 제외합니다. 빈 문자열을 유효한 값으로 쓸 때는 명시적으로 검사하세요.

PREDICATE

구현이 타입 약속을 지킨다

isAdminUser의 role === "admin" 검사 뒤 manageUsers()를 호출합니다. value is AdminUser라는 선언 자체가 외부 데이터를 검증하지는 않습니다.

모든 타입 가드가 never로 끝나야 하는 것은 아닙니다. 판별 유니온의 모든 경우를 처리하는 검사는 별도의 완전성 검사입니다.

위 그림처럼 타입 가드는 실행 중에 확인한 사실을 바탕으로 그 분기의 타입을 좁힙니다. 그래서 어떤 검사를 했는지가 곧 그 분기에서 쓸 수 있는 연산을 정하며, 검사가 확인하지 않은 사실까지 좁혀 주지는 않습니다.

더 깊이: 버전마다 넓어진 좁히기의 범위

제어 흐름 분석이 따라갈 수 있는 범위는 버전을 거치며 넓어졌습니다. TypeScript 4.3까지는 const argIsString = typeof arg === "string";처럼 검사 결과를 변수에 담은 뒤 if (argIsString)으로 분기하면 arg가 좁혀지지 않았습니다. TypeScript 4.4부터는 조건에 쓴 상수가 타입 가드를 담고 있는지 따라가 보고, 그 가드가 const 변수나 readonly 속성, 함수 안에서 바꾸지 않은 매개변수를 검사한 것이면 좁혀 줍니다. 상수를 여러 단계 거쳐도 따라가지만, 릴리스 노트는 끝없이 깊이 따라가지는 않는다고 적습니다.

콜백 안에서도 사정이 달라졌습니다. 예전에는 바깥에서 좁힌 매개변수나 let 변수를 화살표 함수 안에서 쓰면, 그 변수가 다른 곳에서 바뀔 수 있다는 이유로 좁힌 타입을 잃었습니다. TypeScript 5.4부터는 끌어올려지지(hoisting) 않는 함수 안에서 쓴 매개변수와 let 변수에 대해 마지막 대입 지점을 찾고, 함수가 그 뒤에 만들어진다면 좁힌 타입을 그대로 씁니다. 다만 그 변수에 값을 넣는 코드가 안쪽 함수 어딘가에 하나라도 있으면, 그 함수가 언제 불릴지 알 수 없으므로 이 분석은 적용되지 않습니다.

typeof

typeof는 값의 실행 중 타입을 "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function" 가운데 하나의 문자열로 알려 줍니다. 원시 타입을 가를 때 가장 많이 씁니다.

function printValue(value: string | number | boolean) {
  if (typeof value === "string") {
    console.log(`문자열: ${value.length}`);
  } else if (typeof value === "number") {
    console.log(`숫자: ${value * 2}`);
  } else {
    console.log(`불리언: ${!value}`);
  }
}

printValue("hello");
printValue(100);
printValue(true);
문자열: 5
숫자: 200
불리언: false

두 번의 검사를 지나 마지막 else에 이르면 value에 남은 타입은 boolean뿐이므로, !value로 뒤집은 값을 출력합니다. 다만 typeof null도 "object"이므로, typeof value === "object"로 객체를 고를 때는 null을 따로 빼야 합니다.

instanceof

instanceof는 값이 어떤 클래스로 만든 인스턴스인지, 더 정확히는 그 클래스의 prototype이 값의 프로토타입 체인에 있는지 확인합니다. 클래스의 유니온을 가를 때 씁니다.

class Bird {
  fly() { console.log("새가 날아갑니다."); }
  layEggs() { console.log("알을 낳습니다."); }
}

class Fish {
  swim() { console.log("물고기가 헤엄칩니다."); }
  layEggs() { console.log("알을 낳습니다."); }
}

function move(animal: Bird | Fish) {
  if (animal instanceof Bird) {
    animal.fly();
  } else {
    animal.swim();
  }
  animal.layEggs();
}

move(new Bird());
move(new Fish());
새가 날아갑니다.
알을 낳습니다.
물고기가 헤엄칩니다.
알을 낳습니다.

animal instanceof Bird가 참이면 animal은 Bird로, 거짓이면 Fish로 좁혀집니다. layEggs는 두 클래스에 모두 있으므로 분기 밖에서도 부를 수 있습니다.

in

in 연산자는 객체에 그 이름의 속성이 있는지 확인합니다. 객체 자신의 속성뿐 아니라 프로토타입에서 물려받은 속성도 찾으며, 한쪽 후보에만 있는 속성으로 객체 타입의 유니온을 가를 때 씁니다.

interface Car {
  drive(): void;
}

interface Boat {
  sail(): void;
}

function operateVehicle(vehicle: Car | Boat) {
  if ("drive" in vehicle) {
    vehicle.drive();
  } else {
    vehicle.sail();
  }
}

operateVehicle({ drive: () => console.log("자동차 운전!") });
operateVehicle({ sail: () => console.log("보트 항해!") });
자동차 운전!
보트 항해!

Car와 Boat는 인터페이스여서 실행 중에는 남지 않으므로 instanceof를 쓸 수 없고, 대신 drive라는 속성이 있는지로 가릅니다. 2장에서 본 판별 유니온처럼 kind 같은 판별 속성이 있다면, vehicle.kind === "car"처럼 그 값을 비교하는 편이 더 분명합니다.

동등성과 참거짓 검사

=== null이나 !== undefined 같은 동등성 검사도 타입을 좁힙니다. if (user.email)처럼 값 자체를 조건에 쓰면, 그 값이 참으로 취급되는지로 좁히는 참거짓(truthiness) 검사가 됩니다.

function greetUser(user: { name: string; email?: string | null }) {
  console.log(`안녕하세요, ${user.name}님!`);
  if (user.email) {
    console.log(`이메일: ${user.email.toLowerCase()}`);
  } else {
    console.log("이메일 정보가 없습니다.");
  }
}

greetUser({ name: "Alice" });
greetUser({ name: "Bob", email: "[email protected]" });
greetUser({ name: "Charlie", email: null });
안녕하세요, Alice님!
이메일 정보가 없습니다.
안녕하세요, Bob님!
이메일: [email protected]
안녕하세요, Charlie님!
이메일 정보가 없습니다.

참인 분기에서 user.email은 undefined와 null이 빠진 string으로 좁혀지므로 toLowerCase를 쓸 수 있습니다. 다만 참거짓 검사는 빈 문자열 ""과 0, NaN도 거짓으로 보므로, 빈 문자열을 넘기면 이메일이 없는 것으로 처리됩니다. null과 undefined만 빼고 싶다면 user.email != null처럼 두 값을 함께 걸러 내는 느슨한 비교를 씁니다.

구조는 같아도 instanceof는 다르다

타입스크립트는 클래스의 타입도 구조로 비교하므로, 같은 공개 메서드를 가진 객체 리터럴을 Bird 타입의 변수에 넣을 수 있습니다. 하지만 instanceof는 구조가 아니라 실제로 Bird의 생성자로 만들었는지를 봅니다. 앞의 Bird가 있는 파일에 다음을 이어 붙입니다.

const birdLike: Bird = {
  fly() { console.log("객체의 fly 호출"); },
  layEggs() { console.log("객체의 layEggs 호출"); },
};

console.log(birdLike instanceof Bird);
console.log("fly" in birdLike);
false
true

birdLike는 Bird와 구조가 같아 타입 검사를 통과하지만, new Bird()로 만들지 않았으므로 instanceof Bird는 false입니다. 그래서 move(birdLike)는 검사를 통과하는데도, 실행하면 거짓 분기로 들어가 animal.swim is not a function이라는 TypeError로 멈춥니다. 타입의 호환성과 실행 중의 프로토타입 관계는 서로 다른 사실입니다.

인스턴스를 만드는 코드를 직접 관리한다면 instanceof로 충분하지만, 구조만 맞춘 객체도 받아야 한다면 멤버가 있는지로 가르는 편이 안전합니다.

function moveByMembers(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();
  } else {
    animal.swim();
  }
  animal.layEggs();
}

moveByMembers(birdLike);
객체의 fly 호출
객체의 layEggs 호출

이 함수는 입력이 Bird | Fish의 모양을 갖추었다고 전제합니다. 바깥에서 들어온 아무 객체에 fly라는 이름이 있다고 해서 그 값이 함수라는 보장은 없으므로, 모양을 모르는 값은 2장에서 본 것처럼 unknown으로 받아 더 꼼꼼히 검증합니다.

사용자 정의 타입 가드

검사의 내용이 길거나 여러 곳에서 되풀이된다면, 검사를 함수로 묶을 수 있습니다. 반환 타입에 매개변수 is 타입 모양의 타입 술어(type predicate)를 적으면, 그 함수가 true를 돌려준 분기에서 인수가 그 타입으로 좁혀집니다.

interface AdminUser {
  id: number;
  role: "admin";
  manageUsers(): void;
}

interface GeneralUser {
  id: number;
  role: "general";
  viewProfile(): void;
}

type User = AdminUser | GeneralUser;

function isAdminUser(user: User): user is AdminUser {
  return user.role === "admin";
}

function handleUser(user: User) {
  if (isAdminUser(user)) {
    console.log("관리자 권한으로 로그인했습니다.");
    user.manageUsers();
  } else {
    console.log("일반 사용자 권한으로 로그인했습니다.");
    user.viewProfile();
  }
}

handleUser({ id: 1, role: "admin", manageUsers: () => console.log("사용자 관리") });
handleUser({ id: 2, role: "general", viewProfile: () => console.log("프로필 보기") });
관리자 권한으로 로그인했습니다.
사용자 관리
일반 사용자 권한으로 로그인했습니다.
프로필 보기

user is AdminUser는 "이 함수가 true를 돌려주면 user는 AdminUser다"라는 약속입니다. 컴파일러는 이 약속이 본문과 맞는지 검사하지 않으므로, 본문을 return true;로 바꾸어도 진단 없이 모든 사용자가 관리자로 좁혀집니다. 타입 술어를 쓰는 함수는 반환값이 정말 그 타입을 확인한 결과인지 작성자가 직접 살펴야 합니다.

TypeScript 5.5부터는 간단한 검사 함수의 타입 술어를 컴파일러가 추론하기도 합니다. 예를 들어 const values = [1, null, 2].filter((value) => value !== null);에서 화살표 함수는 value is number로 추론되어, values의 타입이 (number | null)[]가 아닌 number[]가 됩니다. 이 기능은 TypeScript 5.5 릴리스 노트에 설명되어 있습니다.

단언 함수

조건문으로 분기하는 대신, 조건이 맞지 않으면 예외를 던지고 맞으면 그다음 코드부터 타입을 좁히는 함수도 만들 수 있습니다. 반환 타입에 asserts 매개변수 is 타입을 적은 함수를 단언 함수(assertion function)라고 합니다.

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError("문자열이 필요합니다.");
  }
}

const input: unknown = "hello";
assertIsString(input);
console.log(input.toUpperCase());
HELLO

assertIsString(input)이 예외 없이 돌아오면, 그 아래에서 input은 string으로 좁혀져 toUpperCase를 쓸 수 있습니다. 문자열이 아닌 값이었다면 TypeError를 던지고 그 뒤의 코드는 실행되지 않습니다. 타입 술어처럼 단언 함수의 약속도 본문이 정말 그 조건을 검사하는지는 작성자가 지켜야 하며, 좁히기의 전체 규칙은 공식 Handbook의 타입 좁히기에 있습니다.

정리

  • 컴파일러는 조건문의 참과 거짓 분기에서 값의 타입을 각각 좁히며, 그 조건에 쓰인 검사를 타입 가드라고 합니다.
  • typeof는 원시 타입을, instanceof는 클래스로 만든 인스턴스를, in은 속성의 존재를 확인하고, 동등성과 참거짓 검사는 null과 undefined 같은 값을 걸러 냅니다.
  • 참거짓 검사는 빈 문자열과 0도 거짓으로 보므로, null과 undefined만 빼려면 != null을 씁니다.
  • 구조가 같은 객체는 타입 검사를 통과해도 instanceof에서는 false가 될 수 있으므로, 구조로 받은 값은 멤버의 존재로 가릅니다.
  • 타입 술어와 단언 함수는 검사를 함수로 묶어 타입을 좁히지만, 컴파일러는 그 본문이 약속과 맞는지 검사하지 않습니다.

연습 문제

1. 두 분기 따라가기

printId에 문자열 "cafe"와 숫자 8.2를 각각 넘기면 무엇이 출력되나요? greetUser({ name: "민지", email: "" })는 어떻게 되며, 빈 문자열도 이메일로 출력하려면 검사를 어떻게 바꾸어야 하나요?

해설 보기

printId("cafe")는 ID: cafe와 대문자 ID: CAFE를, printId(8.2)는 ID: 8.2와 고정 소수점 ID: 8.20을 출력합니다. greetUser에 빈 문자열을 넘기면 참거짓 검사가 거짓으로 보아 이메일 정보가 없습니다.가 출력됩니다. 검사를 user.email != null로 바꾸면 null과 undefined만 걸러지고 빈 문자열은 문자열 분기로 가서 이메일: 이 출력되며, 이 검사도 이메일 주소의 형식까지 확인하지는 않습니다.

2. 잘못된 타입 술어

isAdminUser의 본문을 return user.id === 1;로 바꾸었습니다. 검사를 통과하나요? handleUser({ id: 1, role: "general", viewProfile: () => console.log("프로필 보기") })는 실행하면 어떻게 되나요?

해설 보기

검사를 통과합니다. 컴파일러는 타입 술어의 본문이 정말 AdminUser를 확인하는지 보지 않기 때문입니다. 그런데 이 일반 사용자는 id가 1이므로 isAdminUser가 true를 돌려주고, 관리자 분기에서 없는 manageUsers를 부르다가 user.manageUsers is not a function이라는 TypeError로 멈춥니다. 타입 술어는 판별 속성처럼 그 타입을 정말 가려 주는 사실을 검사해야 합니다.

3. 걸러 낸 배열의 타입

const scores = [80, undefined, 95].filter((score) => score !== undefined);에서 scores의 타입과 값은 무엇인가요? TypeScript 5.5 이전에는 타입이 어떻게 보였을까요?

해설 보기

TypeScript 5.5부터는 화살표 함수가 score is number로 추론되어 scores의 타입이 number[]이고, 값은 [80, 95]입니다. 그 이전 버전에서는 타입 술어가 추론되지 않아 filter가 원래 배열의 요소 타입을 그대로 돌려주므로 (number | undefined)[]로 보였고, (score): score is number => score !== undefined처럼 술어를 직접 적어야 했습니다.

다음 글 조건부 타입에서는 이 글의 분기를 값이 아닌 타입에 적용해, 조건에 따라 다른 타입을 고르는 조건부 타입을 다룹니다.