typeof, instanceof, 판별 유니온
앞 장에서 TypeScript가 코드 흐름을 추적하며 타입을 좁힌다는 것을 배웠습니다. if 문 안에서 typeof를 써봤는데, 이번에는 그 도구들을 체계적으로 살펴봅니다.
TypeScript가 인식하는 좁히기 방법은 크게 세 가지입니다. 기본형(primitive)을 구분하는 typeof, 클래스 인스턴스를 구분하는 instanceof, 그리고 공통 필드로 분기하는 판별 유니온입니다.
typeof로 기본 타입 좁히기
typeof 연산자는 JavaScript에도 있습니다. 값의 타입을 문자열로 반환합니다. TypeScript는 typeof 검사 결과를 이해하고 타입을 좁혀줍니다.
typeof가 반환할 수 있는 값은 다음과 같습니다.
| 타입 | typeof 결과 |
|---|---|
| string | "string" |
| number | "number" |
| bigint | "bigint" |
| boolean | "boolean" |
| symbol | "symbol" |
| undefined | "undefined" |
| function | "function" |
| object | "object" |
// 새 파일: typeof-narrowing.tsfunction describe(value: string | number | boolean): string { if (typeof value === "string") { return `문자열: "${value}" (길이: ${value.length})`; } else if (typeof value === "number") { return `숫자: ${value.toFixed(2)}`; } else { return `불리언: ${value ? "참" : "거짓"}`; }}console.log(describe("TypeScript")); // 문자열: "TypeScript" (길이: 10)console.log(describe(3.14159)); // 숫자: 3.14console.log(describe(true)); // 불리언: 참
주의할 점이 하나 있습니다. typeof null은 "object"를 반환합니다. JavaScript 역사의 오류인데, 타입 좁히기에서 null을 처리할 때는 별도로 검사해야 합니다.
// null을 포함한 유니온에서 typeof만 쓰면 안 됩니다function getLength(value: string | null): number { if (typeof value === "string") { return value.length; // null은 "object"이므로 이 블록에 포함되지 않습니다 } return 0;}// 더 명확하게 null을 검사하는 방법function getLength2(value: string | null): number { if (value !== null) { return value.length; // value는 string입니다 } return 0;}
instanceof로 클래스 좁히기
typeof는 기본형에만 쓸 수 있습니다. 클래스로 만든 객체는 모두 "object"를 반환합니다. 클래스를 구분하려면 instanceof 연산자를 씁니다.
// 새 파일: instanceof-narrowing.tsclass Circle { constructor(public radius: number) {} area(): number { return Math.PI * this.radius ** 2; }}class Rectangle { constructor(public width: number, public height: number) {} area(): number { return this.width * this.height; }}function describeShape(shape: Circle | Rectangle): string { if (shape instanceof Circle) { // 이 블록 안에서 shape는 Circle입니다 return `원: 반지름 ${shape.radius}, 넓이 ${shape.area().toFixed(2)}`; } else { // 이 블록 안에서 shape는 Rectangle입니다 return `직사각형: ${shape.width} × ${shape.height}, 넓이 ${shape.area()}`; }}const c = new Circle(5);const r = new Rectangle(4, 6);console.log(describeShape(c)); // 원: 반지름 5, 넓이 78.54console.log(describeShape(r)); // 직사각형: 4 × 6, 넓이 24
instanceof는 클래스 계층 구조도 이해합니다. 자식 클래스는 부모 클래스의 instanceof 검사를 통과합니다.
class Animal { constructor(public name: string) {}}class Dog extends Animal { bark(): void { console.log("멍멍!"); }}class Cat extends Animal { meow(): void { console.log("야옹~"); }}function makeSound(animal: Dog | Cat): void { if (animal instanceof Dog) { animal.bark(); } else { animal.meow(); } // instanceof Animal로도 검사할 수 있습니다 console.log(`${animal.name}이(가) 소리를 냈습니다.`);}makeSound(new Dog("뽀삐")); // 멍멍! / 뽀삐이(가) 소리를 냈습니다.makeSound(new Cat("나비")); // 야옹~ / 나비이(가) 소리를 냈습니다.
판별 유니온 — 공통 필드로 분기하기
typeof와 instanceof는 강력하지만 한계가 있습니다. 인터페이스는 런타임에 존재하지 않으므로 instanceof로 검사할 수 없습니다. 이럴 때 쓰는 패턴이 판별 유니온(discriminated union)입니다.
핵심 아이디어는 간단합니다. 유니온을 이루는 모든 타입에 같은 이름의 리터럴 타입 필드를 추가합니다. 이 공통 필드로 분기하면 TypeScript가 각 분기에서 정확한 타입을 알 수 있습니다.
// 새 파일: discriminated-union.tsinterface Circle { kind: "circle"; // 리터럴 타입 — 이 필드가 판별자입니다 radius: number;}interface Rectangle { kind: "rectangle"; width: number; height: number;}interface Triangle { kind: "triangle"; base: number; height: number;}type Shape = Circle | Rectangle | Triangle;function getArea(shape: Shape): number { switch (shape.kind) { case "circle": // shape는 Circle입니다 return Math.PI * shape.radius ** 2; case "rectangle": // shape는 Rectangle입니다 return shape.width * shape.height; case "triangle": // shape는 Triangle입니다 return (shape.base * shape.height) / 2; }}const shapes: Shape[] = [ { kind: "circle", radius: 5 }, { kind: "rectangle", width: 4, height: 6 }, { kind: "triangle", base: 3, height: 8 },];shapes.forEach(shape => { console.log(`${shape.kind}: ${getArea(shape).toFixed(2)}`);});// circle: 78.54// rectangle: 24.00// triangle: 12.00
kind 필드의 값이 각 타입을 구분하는 "꼬리표" 역할을 합니다. switch 문에서 shape.kind를 확인하면 TypeScript는 각 case 블록에서 정확한 타입을 알게 됩니다.
판별자 필드 이름은 kind가 아니어도 됩니다. type, tag, variant 등 어떤 이름이든 가능합니다. 중요한 것은 유니온을 이루는 모든 타입에 같은 이름의 필드가 있어야 한다는 점입니다.
실전 예제 — API 응답 처리
판별 유니온은 API 응답 처리에서 특히 유용합니다. 성공과 실패의 구조가 다를 때 이 패턴을 쓰면 안전하게 처리할 수 있습니다.
// 새 파일: api-response.tsinterface ApiSuccess<T> { status: "success"; data: T; timestamp: number;}interface ApiError { status: "error"; errorCode: string; message: string;}type ApiResponse<T> = ApiSuccess<T> | ApiError;interface UserData { id: number; name: string; email: string;}function handleUserResponse(response: ApiResponse<UserData>): void { if (response.status === "success") { // response는 ApiSuccess<UserData>입니다 const user = response.data; // UserData 타입 console.log(`사용자 로드 성공: ${user.name} (${user.email})`); console.log(`응답 시각: ${new Date(response.timestamp).toLocaleString()}`); } else { // response는 ApiError입니다 console.error(`오류 [${response.errorCode}]: ${response.message}`); }}// 성공 응답 예시const successResponse: ApiResponse<UserData> = { status: "success", data: { id: 1, name: "이민준", email: "[email protected]" }, timestamp: Date.now(),};// 실패 응답 예시const errorResponse: ApiResponse<UserData> = { status: "error", errorCode: "USER_NOT_FOUND", message: "해당 사용자를 찾을 수 없습니다.",};handleUserResponse(successResponse);handleUserResponse(errorResponse);
status 필드 하나로 두 타입을 완전히 구분합니다. response.status === "success" 블록 안에서는 response.data에 접근할 수 있고, 오류 블록에서는 response.errorCode와 response.message에 접근할 수 있습니다.
이 패턴을 사용하지 않으면 어떻게 될까요?
// 판별 유니온을 쓰지 않은 경우 — 다루기 불편합니다interface BadApiResponse { success: boolean; data?: UserData; // 있을 수도 없을 수도 있습니다 errorCode?: string; // 있을 수도 없을 수도 있습니다 message?: string;}function handleBadResponse(response: BadApiResponse): void { if (response.success) { // response.data가 undefined일 수 있어서 매번 검사해야 합니다 if (response.data) { console.log(response.data.name); // 불필요한 중첩 } }}
판별 유니온을 쓰면 각 상황의 구조가 명확해지고, TypeScript가 각 분기에서 정확한 타입을 보장해줍니다.
in 연산자로 속성 존재 검사하기
클래스 없이 인터페이스만 있고 판별자 필드도 없다면, in 연산자로 특정 속성의 존재 여부를 검사해 타입을 좁힐 수 있습니다.
// 새 파일: in-narrowing.tsinterface Cat { meow(): void; purr(): void;}interface Dog { bark(): void; fetch(): void;}function interactWithPet(pet: Cat | Dog): void { if ("meow" in pet) { // pet은 Cat입니다 pet.meow(); pet.purr(); } else { // pet은 Dog입니다 pet.bark(); pet.fetch(); }}
"meow" in pet은 pet 객체에 meow 속성이 있는지 확인합니다. TypeScript는 이 검사 결과를 보고 타입을 좁혀줍니다. 다만 이 방법은 판별 유니온보다 덜 명시적입니다. 가능하면 공통 판별자 필드를 추가하는 것이 더 좋습니다.
typeof는 기본형을, instanceof는 클래스를, 판별 유니온은 인터페이스 구분을 담당합니다. 각각의 역할이 다르지만 모두 같은 원리로 작동합니다. TypeScript가 코드 흐름을 보고 타입을 좁혀주는 것입니다.
다음 장에서는 TypeScript의 추론을 신뢰하는 대신 직접 타입을 지정하는 방법을 배웁니다. 강력하지만 잘못 쓰면 위험한 as, 타입 단언입니다.