전체 목록
TypeScriptHard#12

TypeScript에서 타입 가드(Type Guard)란 무엇이며 구현 방법을 설명해주세요.

#TS#타입가드#타입좁히기
힌트

typeof, instanceof, in 연산자 및 사용자 정의 타입 가드를 생각해보세요.

정답 및 해설

TypeScript에서 타입 가드(Type Guard)란 무엇이며 구현 방법을 설명해주세요.

타입 가드(Type Guard)는 런타임에 특정 타입임을 확인하여 TypeScript 컴파일러가 해당 범위 내에서 타입을 좁혀(narrow) 주는 표현식 또는 함수입니다. 유니온 타입처럼 여러 타입이 가능한 상황에서 안전하게 특정 타입의 속성과 메서드에 접근할 수 있게 해줍니다. 타입 가드를 사용하면 as 캐스팅 없이도 타입 안전성을 유지하면서 유연한 코드를 작성할 수 있습니다.


타입 가드가 필요한 이유

TypeScript에서 유니온 타입을 다룰 때 각 타입 고유의 속성에 접근하려면 어떤 타입인지 먼저 확인해야 합니다. 타입 가드 없이는 컴파일 에러가 발생합니다.

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function makeSound(animal: Cat | Dog) {
  // 에러: 'meow'는 Dog에 존재하지 않을 수 있음
  // animal.meow();

  // 타입 가드로 안전하게 접근
  if ('meow' in animal) {
    animal.meow(); // 여기서는 Cat으로 좁혀짐
  } else {
    animal.bark(); // 여기서는 Dog으로 좁혀짐
  }
}

구현 방법 1: typeof 연산자

typeof는 원시 타입(string, number, boolean, symbol, bigint, undefined, function)을 구분할 때 사용합니다.

function formatValue(value: string | number): string {
  if (typeof value === 'string') {
    // 이 블록에서 value는 string 타입
    return value.toUpperCase();
  } else {
    // 이 블록에서 value는 number 타입
    return value.toFixed(2);
  }
}

console.log(formatValue('hello')); // "HELLO"
console.log(formatValue(3.14159)); // "3.14"
function processInput(input: string | number | boolean) {
  if (typeof input === 'string') {
    console.log('문자열 길이:', input.length);
  } else if (typeof input === 'number') {
    console.log('숫자의 제곱:', input * input);
  } else {
    console.log('불리언 값:', input ? '참' : '거짓');
  }
}

typeof의 한계

typeof null'object'를 반환하므로 객체 타입 구분에는 적합하지 않습니다.

typeof null === 'object'; // true (JavaScript의 역사적 버그)
typeof [] === 'object';   // true (배열도 object로 반환됨)

구현 방법 2: instanceof 연산자

instanceof는 클래스 인스턴스를 구분할 때 사용합니다. 프로토타입 체인을 확인하여 해당 클래스의 인스턴스인지 검사합니다.

class HttpError extends Error {
  constructor(
    public statusCode: number,
    message: string
  ) {
    super(message);
    this.name = 'HttpError';
  }
}

class ValidationError extends Error {
  constructor(
    public field: string,
    message: string
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

function handleError(error: HttpError | ValidationError | Error) {
  if (error instanceof HttpError) {
    // HttpError 타입으로 좁혀짐
    console.log(`HTTP ${error.statusCode}: ${error.message}`);
  } else if (error instanceof ValidationError) {
    // ValidationError 타입으로 좁혀짐
    console.log(`검증 오류 - 필드 "${error.field}": ${error.message}`);
  } else {
    console.log(`일반 오류: ${error.message}`);
  }
}
class Rectangle {
  constructor(public width: number, public height: number) {}
  area() { return this.width * this.height; }
}

class Circle {
  constructor(public radius: number) {}
  area() { return Math.PI * this.radius ** 2; }
}

function describeShape(shape: Rectangle | Circle) {
  if (shape instanceof Rectangle) {
    console.log(`사각형: ${shape.width} x ${shape.height}`);
  } else {
    console.log(`원: 반지름 ${shape.radius}`);
  }
  console.log(`넓이: ${shape.area().toFixed(2)}`);
}

구현 방법 3: in 연산자

in 연산자는 객체에 특정 프로퍼티가 존재하는지 확인합니다. 인터페이스처럼 클래스가 아닌 타입을 구분할 때 유용합니다.

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

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

function getAccess(person: Admin | User) {
  if ('permissions' in person) {
    // Admin 타입으로 좁혀짐
    console.log('권한 목록:', person.permissions.join(', '));
  } else {
    // User 타입으로 좁혀짐
    console.log('이메일:', person.email);
  }
}
interface Bird {
  fly: () => void;
  layEggs: () => void;
}

interface Fish {
  swim: () => void;
  layEggs: () => void;
}

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

구현 방법 4: 사용자 정의 타입 가드 (Type Predicate)

함수명(매개변수): 매개변수 is 타입 형식의 반환 타입을 사용해 타입 가드 함수를 직접 정의합니다.

interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

// 사용자 정의 타입 가드 함수
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj
  );
}

function isProduct(obj: unknown): obj is Product {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'title' in obj &&
    'price' in obj
  );
}

function processApiResponse(data: unknown) {
  if (isUser(data)) {
    console.log(`사용자: ${data.name} (${data.email})`);
  } else if (isProduct(data)) {
    console.log(`상품: ${data.title} - ${data.price}원`);
  } else {
    console.log('알 수 없는 데이터 형식');
  }
}

API 응답 검증에 활용

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

function isSuccessResponse<T>(
  response: ApiResponse<T>
): response is ApiResponse<T> & { data: T } {
  return response.success === true && response.data !== undefined;
}

async function fetchUser(id: number) {
  const response: ApiResponse<User> = await fetch(`/api/users/${id}`)
    .then(res => res.json());

  if (isSuccessResponse(response)) {
    // response.data가 User 타입으로 좁혀짐
    console.log(response.data.name);
  } else {
    console.error(response.error);
  }
}

구현 방법 5: Discriminated Union (판별 유니온)

공통 리터럴 타입 프로퍼티를 활용해 자동으로 타입을 좁히는 패턴입니다.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rectangle'; width: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'rectangle':
      return shape.width * shape.height;
  }
}
type NetworkState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; message: string };

function renderUI(state: NetworkState) {
  switch (state.status) {
    case 'idle':
      return '대기 중';
    case 'loading':
      return '로딩 중...';
    case 'success':
      return `결과: ${state.data}`;
    case 'error':
      return `에러: ${state.message}`;
  }
}

구현 방법 6: Assertion Functions

TypeScript 3.7+에서 도입된 asserts 키워드로 조건이 거짓이면 예외를 던지는 타입 가드입니다.

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new TypeError(`Expected string, got ${typeof value}`);
  }
}

function processName(name: unknown) {
  assertIsString(name);
  // 이 시점부터 name은 string 타입
  console.log(name.toUpperCase());
}

실전 활용 예시: 폼 유효성 검사

interface LoginForm {
  username: string;
  password: string;
}

interface RegisterForm extends LoginForm {
  email: string;
  confirmPassword: string;
}

function isRegisterForm(form: LoginForm | RegisterForm): form is RegisterForm {
  return 'email' in form && 'confirmPassword' in form;
}

function handleFormSubmit(form: LoginForm | RegisterForm) {
  if (isRegisterForm(form)) {
    if (form.password !== form.confirmPassword) {
      throw new Error('비밀번호가 일치하지 않습니다.');
    }
    console.log(`회원가입: ${form.username} (${form.email})`);
  } else {
    console.log(`로그인: ${form.username}`);
  }
}

방법별 비교 요약

방법대상 타입특징사용 시기
typeof원시 타입내장 연산자, 간단string, number, boolean 구분
instanceof클래스 인스턴스프로토타입 체인 확인클래스 기반 타입 구분
in객체 프로퍼티인터페이스에 유용특정 속성 존재 여부 확인
사용자 정의모든 타입복잡한 검증 가능API 응답, 복잡한 타입 검증
Discriminated Union유니온 타입컴파일 타임 완전성 검사상태 머신, 명확한 타입 구분
Assertion Functions모든 타입예외 발생 방식전제 조건 검사

핵심 정리

  • 타입 가드는 as 캐스팅의 안전한 대안으로, 런타임 검증과 컴파일 타임 타입 안전성을 동시에 제공합니다.
  • 단순 원시 타입은 typeof, 클래스는 instanceof, 인터페이스는 in 또는 사용자 정의 타입 가드를 사용합니다.
  • Discriminated Union 패턴은 switch문과 함께 사용하면 누락된 케이스를 컴파일 타임에 발견할 수 있어 유지보수성이 높습니다.