1. 개요

이 시리즈를 시작할 때부터 계속 스치듯 등장한 게 있다. body is Envelope, v is { id: number; name: string } 처럼 반환 타입에 is 를 쓰는 함수. 2편과 6편에서 잠깐씩 썼는데, 이번 편에서 이 문법 자체와 그 뒤에 있는 원리, 그리고 잘못 쓰면 왜 위험한지를 제대로 짚는다.


2. 핵심 내용

2-1. 타입 가드의 근본 문제: TypeScript 는 런타임을 모른다

TypeScript 의 타입 검사는 컴파일 시점에 끝난다. 컴파일된 자바스크립트에는 타입 정보가 전혀 남지 않는다. 그런데 API 응답, 사용자 입력, 외부 라이브러리에서 오는 값은 “런타임에 실제로 확인” 해야 진짜 타입을 알 수 있다. 이 둘 사이의 간극을 잇는 게 타입 가드다.

function isString(v: unknown): boolean {
  return typeof v === "string"
}
 
function process(v: unknown) {
  if (isString(v)) {
    console.log(v.toUpperCase()) // Error! v 는 여전히 unknown
  }
}

isString 이 true 를 반환해도 TypeScript 는 이 함수가 boolean 만 돌려준다는 것만 알지, “그 true 가 v 의 타입에 대해 뭘 뜻하는지” 는 모른다. 함수 안의 로직과 함수 밖의 타입 정보가 끊겨있다.

2-2. is 로 그 둘을 잇는다

반환 타입을 매개변수 is 타입 으로 선언하면, 이 함수가 true 를 반환하는 분기에서 그 매개변수를 해당 타입으로 취급하라고 컴파일러에게 알려준다.

function isString(v: unknown): v is string {
  return typeof v === "string"
}
 
function process(v: unknown) {
  if (isString(v)) {
    console.log(v.toUpperCase()) // OK, v 는 string 으로 좁혀짐
  }
}

v is string 은 “이 함수가 true 를 반환하면, 그 시점의 v 는 진짜로 string 이라고 내가(개발자가) 보증한다” 는 선언이다. 함수 몸통의 실제 로직(typeof v === "string")은 컴파일러가 검증해주지 않는다. 타입 가드의 정확성은 전적으로 작성자 책임이다.

2-3. 객체를 검증하는 타입 가드

원시 타입은 typeof 만으로 충분하지만, 객체는 필드를 하나씩 확인해야 한다.

interface Product {
  id: number
  name: string
  price: number
}
 
function isProduct(v: unknown): v is Product {
  if (typeof v !== "object" || v === null) return false
  const obj = v as Record<string, unknown>
  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.price === "number"
  )
}
 
function handleApiResponse(data: unknown) {
  if (isProduct(data)) {
    console.log(`${data.name}: ${data.price}원`) // 안전하게 접근
  } else {
    console.error("예상과 다른 응답 형식", data)
  }
}

as Record<string, unknown> 을 쓰는 이유는, 필드를 하나씩 읽어보기 전까지는 obj 가 정말 Product 모양인지 알 수 없어서다. 이 단언 자체는 안전을 보장하지 않는다. 안전은 그 아래 typeof 검사들이 실제로 만들어낸다. 이 패턴, 즉 unknown 값을 검증해서 안전한 타입으로 좁히는 건 API 경계에서 가장 자주 쓰인다.

2-4. 잘못 만들면 생기는 문제: 거짓 보증

타입 가드가 실제 조건보다 느슨하면, 컴파일러는 그 거짓 보증을 그대로 믿는다.

function isProduct(v: unknown): v is Product {
  return typeof v === "object" && v !== null // id, name, price 확인 안 함
}
 
function handleApiResponse(data: unknown) {
  if (isProduct(data)) {
    console.log(data.price.toFixed(2)) // 컴파일 통과, price 가 없으면 런타임 에러
  }
}

{} 도 { foo: "bar" } 도 다 isProduct 를 통과한다. 컴파일러는 data.price 를 number 로 믿고 .toFixed(2) 를 허용하지만, 실제로 price 필드가 없으면 undefined.toFixed 로 터진다. is 는 컴파일러에게 거는 약속이지 자동 검증이 아니라는 걸 이 예시가 잘 보여준다. 판별 조건을 필요한 만큼 좁게(또는 정확하게) 쓰는 게 타입 가드 작성의 핵심이다. 비슷한 이유로 판별 조건이 도메인 값과 우연히 겹쳐서 오판되는 사례를 응답 봉투는 어디서 벗기나에서 다룬 적이 있다.

2-5. 배열 필터링에서의 활용

타입 가드는 Array.prototype.filter 와 함께 쓸 때 특히 빛을 발한다. 일반 조건문은 필터링은 해도 타입을 좁혀주지 않는다.

const values: (string | null)[] = ["a", null, "b", null, "c"]
 
const filtered1 = values.filter((v) => v !== null)
// 타입은 여전히 (string | null)[] — null 이 실제로는 없는데도
 
function isNotNull<T>(v: T | null): v is T {
  return v !== null
}
 
const filtered2 = values.filter(isNotNull)
// 타입이 string[] 로 정확히 좁혀진다

filtered1 은 런타임에는 null 이 없지만 타입은 여전히 (string | null)[] 로 남는다. TypeScript 의 filter 오버로드가 일반 콜백의 반환값(boolean)만으로는 원소 타입을 좁혀줄 수 없기 때문이다. 타입 가드 함수를 넘기면 filter 가 그 시그니처를 인식해서 반환 배열의 타입 자체를 좁혀준다.


3. 마무리

TypeScript 기초 시리즈는 여기서 마무리한다. 구조적 타이핑에서 시작해서 유니온·narrowing, 제네릭, 유틸리티 타입, interface 와 type, unknown, 그리고 커스텀 타입 가드까지 왔다. 공통 줄기는 하나였다. 타입은 실제 데이터의 모양을 최대한 정직하게 따라가야 하고, “모른다” 를 any 로 감추는 대신 unknown 과 타입 가드로 명시적으로 다뤄야 한다는 것.

요약

  • 매개변수 is 타입 은 런타임 검사 함수와 컴파일 타임 타입 좁히기를 잇는 문법이다. 함수 몸통이 실제로 그 타입을 보장하는지는 컴파일러가 아니라 작성자가 책임진다.
  • 객체 검증은 unknown 을 받아 필드를 하나씩 typeof 로 확인하는 식으로 만든다. API 경계에서 가장 자주 쓰는 패턴이다.
  • 판별 조건이 실제보다 느슨하면 거짓 보증이 되어 런타임 에러가 타입 체크를 뚫고 나온다.
  • Array.prototype.filter 에 타입 가드를 넘기면 일반 조건문과 달리 결과 배열의 타입 자체가 좁혀진다.