본문 바로가기

타입 가드

1. 타입 가드

타입 가드를 사용하면 특정 스코프 내에서 타입을 보장할 수 있습니다. "이 객체가 특정 타입인지 확인하고, 맞다면 TypeScript에게 그 사실을 알려주는" 방법이라고 생각하시면 됩니다.

1.1 typeof 타입 가드

typeof는 JavaScript의 typeof처럼 Array를 Object로 반환합니다. 따라서 객체를 취급할 때에는 주의해야 합니다.

function processValue(value: string | number) {
    if (typeof value === "string") {
        return value.toUpperCase(); // string으로 타입 보장
    }
    return value.toFixed(2); // number로 타입 보장
}

typeof는 javascript와 typescript 모두 연산이 정확하지 않기 때문에 아래와 같이 별도의 사용자 정의 함수를 만들어 사용하는 것을 권합니다. javascript에서 사용 가능한 코드입니다.

function typeCheck(value) {
    const return_value = Object.prototype.toString.call(value);
    const type = return_value.substring(
        return_value.indexOf(" ") + 1,
        return_value.indexOf("]")
    );
    return type.toLowerCase();
}

타입스크립트에서는 아래와 같이 사용할 수 있습니다.

function typeCheck(value: any): string {
    const return_value = Object.prototype.toString.call(value);
    const type = return_value.substring(
        return_value.indexOf(" ") + 1,
        return_value.indexOf("]")
    );
    return type.toLowerCase();
}

console.log(typeCheck([]));
console.log(typeCheck(null));

1.2 instanceof 타입 가드

class Dog {
    bark() { return "Woof!"; }
}

class Cat {
    meow() { return "Meow!"; }
}

function makeSound(animal: Dog | Cat) {
    if (animal instanceof Dog) {
        return animal.bark();
    }
    return animal.meow();
}

const dog = new Dog();
const cat = new Cat();

console.log(makeSound(dog)); // Woof!
console.log(makeSound(cat)); // Meow!

1.3 사용자 정의 타입 가드

interface Fish {
    swim(): void;
}

interface Bird {
    fly(): void;
}

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird) {
    if (isFish(pet)) {
        pet.swim();
    } else {
        pet.fly();
    }
}

const fish = {
    swim: () => console.log("물고기가 헤엄칩니다.")
};

const bird = {
    fly: () => console.log("새가 날아갑니다.")
};

move(fish); // "물고기가 헤엄칩니다 🐠"
move(bird); // "새가 날아갑니다 🐦"

pet is Fish는 TypeScript의 타입 술어(Type Predicate)로, 함수가 true를 반환할 때 해당 매개변수가 Fish 타입이라는 것을 TypeScript에게 알려주는 역할을 합니다. 쉽게 말해서, 이 조건이 참이면 pet은 Fish 타입이다라고 TypeScript의 타입 시스템에게 알려주는 것입니다.

(pet as Fish).swim !== undefined는 타입 단언(Type Assertion)을 사용한 코드입니다. pet 객체를 Fish 타입이라고 가정(타입 단언)하고, swim이라는 메서드가 존재하는지 확인합니다. 만약 swim 메서드가 존재한다면 true를 반환하여 이 객체가 Fish 타입이라고 판단합니다. 즉, "물고기라면 반드시 가지고 있어야 할 swim 메서드가 있는지 확인"하는 것입니다.

1.4 in 연산자 타입 가드

in 연산자는 객체에 특정 속성이 존재하는지 확인할 때 사용합니다. 타입 단언보다 더 간단하고 안전하게 타입을 좁힐 수 있습니다.

interface Developer {
    name: string;
    code(): void;
}

interface Designer {
    name: string;
    design(): void;
}

function work(person: Developer | Designer) {
    if ('code' in person) {
        person.code();  // Developer로 타입이 좁혀짐
    } else {
        person.design();  // Designer로 타입이 좁혀짐
    }
}

const dev: Developer = {
    name: "철수",
    code: () => console.log("코드를 작성합니다.")
};

const designer: Designer = {
    name: "영희",
    design: () => console.log("디자인을 합니다.")
};

work(dev);      // "코드를 작성합니다."
work(designer); // "디자인을 합니다."

in 연산자는 배열 타입을 구분할 때도 유용합니다.

function processData(data: string | string[]) {
    if (Array.isArray(data)) {
        console.log(`배열 길이: ${data.length}`);
        data.forEach(item => console.log(item));  // string[]로 타입 보장
    } else {
        console.log(`문자열 길이: ${data.length}`);  // string으로 타입 보장
    }
}

processData("안녕하세요");
processData(["사과", "바나나", "오렌지"]);

null이나 undefined를 체크할 때도 타입 가드가 동작합니다.

function printLength(text: string | null | undefined) {
    // 방법 1: truthy 체크
    if (text) {
        console.log(text.length);  // string으로 타입 보장
    }

    // 방법 2: null 명시적 체크
    if (text !== null && text !== undefined) {
        console.log(text.length);
    }

    // 방법 3: nullish 체크 (null과 undefined 모두 체크)
    if (text != null) {
        console.log(text.length);
    }
}

function processUser(user: { name: string; age?: number }) {
    if (user.age !== undefined) {
        console.log(`내년 나이: ${user.age + 1}세`);  // number로 타입 보장
    }
}

processUser({ name: 'Sunny', age: 3000 })

2. 실전 타입 가드 패턴

2.1 Discriminated Union 패턴

Discriminated Union은 공통 속성(discriminant)을 사용해 타입을 구분하는 패턴입니다. 유니온 타입의 각 멤버를 확실하게 구별할 수 있도록 공통 판별자 속성을 두는 패턴이며, 주로 리터럴 타입으로 처리합니다. API 응답 처리 등 실무에서 자주 사용됩니다.

// API 응답 타입 정의
interface SuccessResponse {
    status: 'success'; // 공통 판별자: 고유한 리터럴 값
    data: {
        userId: number;
        username: string;
    };
}

interface ErrorResponse {
    status: 'error'; // 공통 판별자: 고유한 리터럴 값
    error: {
        code: number;
        message: string;
    };
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse) {
    // status 필드로 타입 자동 판별
    if (response.status === 'success') {
        console.log(`사용자: ${response.data.username}`);
        // response는 자동으로 SuccessResponse 타입
    } else {
        console.log(`에러 ${response.error.code}: ${response.error.message}`);
        // response는 자동으로 ErrorResponse 타입
    }
}

// 사용 예시
const success: ApiResponse = {
    status: 'success',
    data: { userId: 1, username: 'john' }
};

const error: ApiResponse = {
    status: 'error',
    error: { code: 404, message: '사용자를 찾을 수 없습니다' }
};

handleResponse(success);  // "사용자: john"
handleResponse(error);    // "에러 404: 사용자를 찾을 수 없습니다"

2.2 switch문 활용

여러 타입을 처리해야 할 때 switch문을 사용하면 코드가 더 깔끔해집니다.

type NetworkStatus = 'online' | 'offline' | 'connecting';

interface NetworkState {
    status: NetworkStatus;
    lastUpdate: Date;
}

function getStatusMessage(state: NetworkState): string {
    switch (state.status) {
        case 'online':
            return '✓ 연결됨';
        case 'offline':
            return '✗ 연결 끊김';
        case 'connecting':
            return '⟳ 연결 중...';
        default:
            // 모든 케이스를 처리했는지 컴파일 타임에 확인
            const exhaustiveCheck: never = state.status;
            throw new Error(`처리되지 않은 상태: ${exhaustiveCheck}`);
    }
}

const state: NetworkState = {
    status: 'online',
    lastUpdate: new Date()
};

console.log(getStatusMessage(state));  // "✓ 연결됨"

never 타입을 사용하면 모든 경우를 처리했는지 TypeScript가 검사해줍니다. 만약 새로운 상태가 추가되면 컴파일 에러가 발생해 놓친 케이스를 바로 알 수 있습니다.

2.3 복합 조건 타입 가드

여러 조건을 결합해서 더 복잡한 타입 가드를 만들 수 있습니다.

interface Admin {
    role: 'admin';
    permissions: string[];
    department: string;
}

interface User {
    role: 'user';
    email: string;
    verified: boolean;
}

interface Guest {
    role: 'guest';
    sessionId: string;
}

type Person = Admin | User | Guest;

// 복합 조건으로 권한 체크
function canEditContent(person: Person): boolean {
    if (person.role === 'admin' && person.permissions.includes('edit')) {
        return true;
    }
    return false;
}

// 타입별로 다른 정보 추출
function getContactInfo(person: Person): string | null {
    if (person.role === 'user') {
        return person.verified ? person.email : null;
    }
    if (person.role === 'admin') {
        return `${person.department} 부서 관리자`;
    }
    return null;
}

// 사용 예시
const admin: Admin = {
    role: 'admin',
    permissions: ['edit', 'delete'],
    department: '개발'
};

const user: User = {
    role: 'user',
    email: 'user@example.com',
    verified: true
};

console.log(canEditContent(admin));     // true
console.log(getContactInfo(user));      // "user@example.com"
console.log(getContactInfo(admin));     // "개발 부서 관리자"

3. 타입 가드 주의사항

3.1 타입 단언 vs 타입 가드 비교

타입 단언과 타입 가드는 비슷해 보이지만 안전성에 큰 차이가 있습니다.

// ❌ 타입 단언 - 런타임 안전성 없음
function processInputBad(input: unknown) {
    const str = input as string;  // 위험! 컴파일은 되지만 런타임 에러 가능
    return str.toUpperCase();
}

// 런타임 에러 발생!
// processInputBad(123);  // TypeError: str.toUpperCase is not a function

// ✅ 타입 가드 - 런타임 안전성 보장
function processInputGood(input: unknown) {
    if (typeof input === 'string') {
        return input.toUpperCase();  // 안전!
    }
    throw new Error('문자열이 아닙니다');
}

// 안전하게 에러 처리
try {
    processInputGood(123);
} catch (e) {
    console.log('에러가 발생했습니다');  // 에러가 발생했습니다
}

타입 단언을 사용해야 하는 경우:

  • 외부 라이브러리의 타입이 부정확할 때
  • TypeScript보다 개발자가 타입을 더 정확히 알고 있을 때
  • DOM 요소를 다룰 때 (예: document.getElementById('app') as HTMLDivElement)

타입 가드를 사용해야 하는 경우:

  • 런타임에 실제로 타입을 확인해야 할 때
  • 사용자 입력이나 외부 데이터를 처리할 때
  • 타입 안전성이 중요한 비즈니스 로직

3.2 옵셔널 체이닝과 함께 사용하기

중첩된 객체를 안전하게 다루려면 타입 가드와 옵셔널 체이닝을 함께 사용합니다.

interface Product {
    id: number;
    name: string;
    details?: {
        description?: string;
        specs?: {
            weight?: number;
            dimensions?: string;
        };
    };
}

// ❌ 타입 가드 없이 접근
function getWeightBad(product: Product): number {
    return product.details.specs.weight;  // 에러! undefined일 수 있음
}

// ✅ 옵셔널 체이닝 사용
function getWeightWithChaining(product: Product): number | undefined {
    return product.details?.specs?.weight;  // 안전하지만 undefined 가능
}

// ✅ 타입 가드와 함께 사용 (가장 안전)
function getWeightSafe(product: Product): number | null {
    if (product.details?.specs?.weight !== undefined) {
        return product.details.specs.weight;  // number로 타입 보장
    }
    return null;
}

// 사용 예시
const product1: Product = {
    id: 1,
    name: '노트북',
    details: {
        specs: {
            weight: 1.5
        }
    }
};

const product2: Product = {
    id: 2,
    name: '키보드'
};

console.log(getWeightSafe(product1));  // 1.5
console.log(getWeightSafe(product2));  // null

옵셔널 체이닝(?.)을 사용하면 중간에 null이나 undefined가 있어도 에러가 발생하지 않고 안전하게 접근할 수 있습니다.

interface Company {
    name: string;
    address?: {
        city?: string;
        zipCode?: string;
    };
}

function getCityName(company: Company): string {
    // 타입 가드로 안전하게 처리
    if (company.address?.city) {
        return company.address.city.toUpperCase();  // string으로 보장
    }
    return '주소 정보 없음';
}

const company1: Company = {
    name: '위니브',
    address: {
        city: '제주'
    }
};

const company2: Company = {
    name: '테스트회사'
};

console.log(getCityName(company1));  // "제주"
console.log(getCityName(company2));  // "주소 정보 없음"

4. 연습문제

  1. 다음과 같이 문자열 또는 숫자를 받아서 해당 값의 길이를 반환하는 함수를 작성하세요.
  • 문자열이 들어오면 문자열의 길이를 반환
  • 숫자가 들어오면 숫자를 문자열로 변환한 후 그 길이를 반환
  1. 다음과 같이 두 가지 타입의 동물 객체가 있습니다. 각 동물의 특성에 맞게 소리를 출력하는 함수를 작성하세요.
class Dog {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    bark() {
        return "멍멍!";
    }
}

class Cat {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    meow() {
        return "야옹!";
    }
}

// 여기에 makeSound 함수를 작성하세요.
// Dog나 Cat을 받아서 각각 알맞은 소리를 반환해야 합니다.
  1. 다음 코드에 타입 가드를 추가해보세요.
interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

type Shape = Square | Rectangle | Circle;

// 도형의 면적을 계산하는 함수를 작성하세요.
function calculateArea(shape: Shape): number {
    // 여기에 구현
}

5. 연습문제 정답

  1. 문자열 또는 숫자를 받아서 해당 값의 길이를 반환하는 함수
function getLength(value: string | number): number {
    if (typeof value === "string") {
        return value.length;
    }
    return String(value).length;
}

// 사용 예시
console.log(getLength("hello"));  // 5
console.log(getLength(12345));    // 5
  1. 각 동물의 특성에 맞게 소리를 출력하는 함수
class Dog {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    bark() {
        return "멍멍!";
    }
}

class Cat {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    meow() {
        return "야옹!";
    }
}

function makeSound(animal: Dog | Cat): string {
    if (animal instanceof Dog) {
        return `${animal.name}이(가) ${animal.bark()}`;
    }
    return `${animal.name}이(가) ${animal.meow()}`;
}

// 사용 예시
const dog = new Dog("멍멍이");
const cat = new Cat("야옹이");

console.log(makeSound(dog));  // "멍멍이가 멍멍!"
console.log(makeSound(cat));  // "야옹이가 야옹!"
  1. 도형의 면적을 계산하는 함수 (타입 가드 사용)
interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

type Shape = Square | Rectangle | Circle;

// 타입 가드 함수들
function isSquare(shape: Shape): shape is Square {
    return shape.kind === "square";
}

function isRectangle(shape: Shape): shape is Rectangle {
    return shape.kind === "rectangle";
}

function isCircle(shape: Shape): shape is Circle {
    return shape.kind === "circle";
}

function calculateArea(shape: Shape): number {
    if (isSquare(shape)) {
        return shape.size * shape.size;
    }
    
    if (isRectangle(shape)) {
        return shape.width * shape.height;
    }
    
    if (isCircle(shape)) {
        return Math.PI * shape.radius * shape.radius;
    }
    
    // 모든 케이스를 처리했지만, TypeScript의 타입 체크를 위해 필요
    const unreachable: never = shape;
    throw new Error(`Unhandled shape type: ${unreachable}`);
}

// 사용 예시
const square: Square = { kind: "square", size: 5 };
const rectangle: Rectangle = { kind: "rectangle", width: 4, height: 6 };
const circle: Circle = { kind: "circle", radius: 3 };

console.log(calculateArea(square));    // 25
console.log(calculateArea(rectangle)); // 24
console.log(calculateArea(circle));    // 약 28.27