타입 가드와 타입 좁히기
typeof·instanceof·in·동등성 검사와 사용자 정의 타입 가드로 유니온 값의 실제 타입을 안전하게 좁힙니다.
타입스크립트는 정적 타입으로 코드 안정성을 높여 줍니다.
하지만 런타임에서는 변수의 실제 타입을 확인해 분기 처리해야 하는 상황이 자주 생깁니다.
예를 들어 string | number 같은 유니온 타입 변수는
실제 값이 문자열인지 숫자인지에 따라 실행 로직이 달라집니다.
이러한 상황에서 유용하게 사용되는 것이 바로 타입 가드(Type Guard)입니다.
타입 가드는 특정 스코프(코드 블록) 내에서 변수의 타입을 더욱 구체적인 타입으로 좁히는(Narrowing) 역할을 합니다.
이를 통해 우리는 런타임에 안전하게 해당 타입의 속성이나 메서드에 접근할 수 있게 됩니다.
타입 좁히기의 개념
TYPE / RUNTIME / CONTRACT
같은 입력도 검사한 분기에서 다른 타입이 된다
타입 가드는 실행 중 확인한 사실을 이용해 현재 분기의 타입을 좁힙니다. 각 검사와 사용할 수 있는 연산을 함께 읽으세요.
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로 끝나야 하는 것은 아닙니다. 판별 유니온의 모든 경우를 처리하는 검사는 별도의 완전성 검사입니다.
타입 좁히기 (Type Narrowing)는 타입스크립트 컴파일러가 코드의 특정 지점에서 변수 타입을 원래 선언보다 더 구체적으로 인식하는 과정입니다.
이는 주로 조건문(if, else if), 루프, 특정 연산자 사용을 통해 발생합니다.
타입 좁히기가 이루어지면 해당 코드 블록 안에서 변수는 좁혀진 타입의 속성과 메서드만 가진 것으로 간주되어 타입 안전성이 높아집니다.
function printId(id: string | number) {
// 처음에는 'id'의 타입이 'string | number'입니다.
console.log(`ID: ${id}`);
// id.toUpperCase(); // Error: 'string | number' 형식에 'toUpperCase' 속성이 없습니다.
// number 타입에는 이 메서드가 없기 때문입니다.
// 타입 가드를 사용하여 타입을 좁힙니다.
if (typeof id === 'string') {
// 이 if 블록 안에서 'id'의 타입은 'string'으로 좁혀집니다.
console.log(`대문자 ID: ${id.toUpperCase()}`);
} else {
// 이 else 블록 안에서 'id'의 타입은 'number'로 좁혀집니다.
console.log(`고정 소수점 ID: ${id.toFixed(2)}`);
}
}
printId("abc123DEF"); // ID: abc123DEF, 대문자 ID: ABC123DEF
printId(123.456); // ID: 123.456, 고정 소수점 ID: 123.46위 예시에서 typeof id === 'string' 조건문이 바로 타입 가드입니다.
이 조건이 참일 경우, 타입스크립트는 if 블록 내에서 id의 타입을 string으로 좁혀줍니다.
주요 타입 가드 종류
타입스크립트에서 기본적으로 제공하는 몇 가지 유용한 타입 가드들이 있습니다.
typeof 연산자
원시 타입(string, number, boolean, symbol, undefined, bigint)에 대한 타입을 확인하는 데 사용됩니다.
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"); // 문자열: 5
printValue(100); // 숫자: 200
printValue(true); // 불리언: falseinstanceof 연산자
클래스의 인스턴스인지 확인하는 데 사용됩니다.
특정 클래스의 인스턴스 또는 해당 클래스를 상속받은 인스턴스에 대해 타입을 좁힐 수 있습니다.
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(); // 'animal'은 Bird 타입으로 좁혀짐
} else {
animal.swim(); // 'animal'은 Fish 타입으로 좁혀짐
}
animal.layEggs(); // 'Bird'와 'Fish' 모두 layEggs 메서드를 가지므로 안전하게 호출 가능
}
move(new Bird()); // 새가 날아갑니다., 알을 낳습니다.
move(new Fish()); // 물고기가 헤엄칩니다., 알을 낳습니다.in 연산자
객체에 특정 속성(property)이 존재하는지 확인하는 데 사용됩니다.
유니온 타입으로 묶인 객체 타입에서 특정 속성의 유무로 타입을 구분할 때 유용합니다.
interface Car {
drive(): void;
}
interface Boat {
sail(): void;
}
function operateVehicle(vehicle: Car | Boat) {
if ('drive' in vehicle) {
vehicle.drive(); // 'vehicle'은 Car 타입으로 좁혀짐
} else {
vehicle.sail(); // 'vehicle'은 Boat 타입으로 좁혀짐
}
}
class MyCar implements Car { drive() { console.log('자동차 운전!'); } }
class MyBoat implements Boat { sail() { console.log('보트 항해!'); } }
operateVehicle(new MyCar()); // 자동차 운전!
operateVehicle(new MyBoat()); // 보트 항해!동등성 검사와 truthiness
null 또는 undefined와 같은 리터럴 값과의 동등성 검사를 통해 타입을 좁힐 수 있습니다.
특히 선택적 속성이나 null이 될 수 있는 타입(string | null)에서 유용합니다. 다음 예제는 동등성 연산 대신 truthiness를 사용하므로 빈 문자열도 없는 값으로 처리합니다.
function greetUser(user: { name: string; email?: string | null }) {
console.log(`안녕하세요, ${user.name}님!`);
if (user.email) { // null·undefined·빈 문자열이 아닐 때
// 이 블록 안에서 user.email의 타입은 string으로 좁혀집니다.
console.log(`이메일: ${user.email.toLowerCase()}`);
} else {
console.log("이메일 정보가 없습니다.");
}
}
greetUser({ name: "Alice" }); // 안녕하세요, Alice님!, 이메일 정보가 없습니다.
greetUser({ name: "Bob", email: "[email protected]" }); // 안녕하세요, Bob님!, 이메일: [email protected]
greetUser({ name: "Charlie", email: null }); // 안녕하세요, Charlie님!, 이메일 정보가 없습니다.사용자 정의 타입 가드
기본 제공되는 타입 가드로는 충분하지 않거나, 더 복잡한 로직으로 타입을 좁히고 싶을 때 사용자 정의 타입 가드(User-Defined Type Guards)를 직접 만들 수 있습니다.
사용자 정의 타입 가드 함수는 반환 타입으로 parameterName is Type 형태의 타입 프레디케이트(Type Predicate)를 가집니다.
interface AdminUser {
id: number;
role: 'admin';
manageUsers(): void;
}
interface GeneralUser {
id: number;
role: 'general';
viewProfile(): void;
}
type User = AdminUser | GeneralUser;
// isAdminUser는 사용자 정의 타입 가드 함수입니다.
// user is AdminUser: true 분기에서 AdminUser로 좁히겠다는 계약이며 구현의 정확성은 작성자 책임입니다.
function isAdminUser(user: User): user is AdminUser {
return (user as AdminUser).role === 'admin';
}
function handleUser(user: User) {
if (isAdminUser(user)) {
// 이 블록 안에서 user는 AdminUser 타입으로 좁혀집니다.
console.log("관리자 권한으로 로그인했습니다.");
user.manageUsers();
} else {
// 이 블록 안에서 user는 GeneralUser 타입으로 좁혀집니다.
console.log("일반 사용자 권한으로 로그인했습니다.");
user.viewProfile();
}
}
const admin: AdminUser = { id: 1, role: 'admin', manageUsers: () => console.log("사용자 관리") };
const general: GeneralUser = { id: 2, role: 'general', viewProfile: () => console.log("프로필 보기") };
handleUser(admin); // 관리자 권한으로 로그인했습니다., 사용자 관리
handleUser(general); // 일반 사용자 권한으로 로그인했습니다., 프로필 보기user is AdminUser는 함수가 true를 반환할 때, 첫 번째 매개변수 user가 AdminUser 타입임을 타입스크립트 컴파일러에게 알려줍니다.
이처럼 사용자 정의 타입 가드를 사용하면 런타임 로직과 타입 검사를 긴밀하게 통합할 수 있습니다.
타입 가드는 런타임 검사와 컴파일러의 타입 추론을 함께 설계해야 효과가 큽니다.
타입 좁히기가 잘못 설계되면 분기 안의 타입은 좁아져 보여도 실제 런타임 값과 어긋날 수 있습니다.
타입 가드와 타입 좁히기는 타입스크립트의 유니온 타입과 함께 강력한 시너지를 발휘하는 기능입니다.
런타임에 동적으로 변수의 타입을 확인하고 그에 따라 적절한 타입의 멤버에 안전하게 접근함으로써, 런타임 오류를 줄이고 코드의 견고성을 크게 향상시킬 수 있습니다.
typeof, instanceof, in 연산자와 더불어 사용자 정의 타입 가드를 적절히 활용하는 것이 중요합니다.
생성 방식까지 가정한 분기인지 확인하기
위 move의 두 호출은 new Bird()와 new Fish()로 만든 인스턴스를 전달합니다. 이 조건에서 instanceof Bird의 거짓 분기는 물고기를 선택합니다. 하지만 이 예제의 클래스에는 공개 메서드만 있으므로, 같은 메서드를 가진 객체 리터럴도 구조상 Bird 타입에 대입할 수 있습니다. 타입 호환성과 생성자의 프로토타입 연결은 서로 다른 사실입니다.
예시
위 Bird 선언이 있는 파일에 다음을 이어서 추가합니다. birdLike는 fly와 layEggs를 직접 가진 객체입니다. 타입 오류가 있는지와 두 검사 결과를 실행 전에 각각 예상해 보세요.
const birdLike: Bird = {
fly() { console.log('객체의 fly 호출'); },
layEggs() { console.log('객체의 layEggs 호출'); }
};
console.log(birdLike instanceof Bird); // false
console.log('fly' in birdLike); // true
// move(birdLike); // 구조 타입 검사는 통과하지만 swim이 없어 실행 중 실패함공개 메서드의 구조는 맞지만 Bird.prototype을 통해 생성하지 않았으므로 첫 결과는 false입니다. 이 객체를 원래 move에 전달하면 거짓 분기의 swim()을 호출하려 하므로 실패합니다. 생성자를 통제하는 코드에서는 원래 방식이 적절하고, 구조로 받은 새·물고기 객체를 처리하려면 아래처럼 두 계약을 구별하는 메서드의 존재를 검사할 수 있습니다.
function moveByMembers(animal: Bird | Fish) {
if ('fly' in animal) {
animal.fly();
} else {
animal.swim();
}
animal.layEggs();
}
moveByMembers(birdLike);결과는 객체의 fly 호출, 객체의 layEggs 호출 두 줄입니다. 이 함수는 선언한 Bird | Fish 구조를 만족하는 입력을 전제로 합니다. 임의의 외부 객체에 fly라는 이름이 있다는 것만으로 함수 값인지까지 보장하는 검증기는 아닙니다. 앞의 MyCar 메서드처럼 프로토타입에 있는 속성도 in에 포함됩니다.
직접 분기를 따라가기
원래 printId에 문자열 "cafe"와 숫자 8.2를 각각 전달하면 무엇이 출력될까요? greetUser에 { name: "민지", email: "" }를 전달했을 때 빈 문자열을 유지하려면 검사 조건을 어떻게 바꿀지도 적어보세요. email의 타입은 원래대로 선택적 string | null이고, null과 undefined만 없는 값으로 취급한다고 가정합니다.
분기와 조건 확인
각 호출은 먼저 ID: cafe 또는 ID: 8.2를 출력합니다. 이어 문자열은 toUpperCase() 분기에서 대문자 ID: CAFE, 숫자는 toFixed(2) 분기에서 고정 소수점 ID: 8.20을 출력합니다. 원래 이메일 truthiness 검사는 빈 문자열을 없는 값으로 취급합니다. user.email != null이면 null과 undefined만 제외하므로 빈 문자열도 문자열 분기로 갑니다. 이 검사도 이메일 주소의 형식을 확인하는 것은 아닙니다.
검사마다 무엇을 확인했는지 구분하는 설명은 TypeScript의 타입 좁히기와 클래스의 구조적 관계에서 대조할 수 있습니다.