TypeScript의 type guard와 assertion function

1. 개요

TypeScript는 typeof, instanceof, 또는 ===로 타입 narrowing을 잘 처리합니다. 하지만 검사 로직이 복잡해져서 별도 함수로 분리하면 컴파일러가 그 검사 결과를 더 이상 ‘볼’ 수 없습니다.

이 글에서는 이를 해결하는 두 가지 도구를 소개합니다. 바로 type guard(value is T)와 assertion function(asserts value is T)입니다. 이 글을 읽고 나면 이들을 작성하는 방법, API에서 unknown 데이터를 검증하는 방법, 테스트 작성 방법, 그리고 이들이 컴파일러를 몰래 속이는 경우들을 알게 될 겁니다.

2. 기본 narrowing과 그 한계

주문 도메인을 예시로 계속 사용하겠습니다:

type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };

type Order = PendingOrder | PaidOrder | CancelledOrder;

function describe(order: Order): string {
  if (order.status === "paid") {
    return `주문 ${order.id}은(는) ${order.paidAt.toISOString()}에 결제되었습니다`;
  }
  return `주문 ${order.id}은(는) ${order.status} 상태입니다`;
}

Order는 판별 필드 status를 가진 discriminated union입니다. if 브랜치 안에서는 TypeScript가 order가 PaidOrder임을 알고 있어서 paidAt에 접근할 수 있습니다.

이제 그 조건을 재사용하려고 별도 함수로 분리해 봅시다:

function isPaid(order: Order): boolean {
  return order.status === "paid";
}

function describe(order: Order): string {
  if (isPaid(order)) {
    return `${order.paidAt.toISOString()}에 결제됨`;
    //                  ^^^^^^ 오류
  }
  return "아직 결제되지 않음";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
  Property 'paidAt' does not exist on type 'PendingOrder'.

isPaid 함수는 boolean을 반환합니다. 이 boolean 타입은 order에 대한 정보를 담고 있지 않으므로 컴파일러가 타입을 좁힐 수 없습니다. 정확히 이 문제를 해결하기 위해 type guard가 만들어졌습니다.

3. 사용자 정의 type guard

3.1. value is T 문법

boolean에서 type predicate로 반환 타입을 변경하기만 하면 됩니다:

function isPaidOrder(order: Order): order is PaidOrder {
  return order.status === "paid";
}

function describe(order: Order): string {
  if (isPaidOrder(order)) {
    return `${order.paidAt.toISOString()}에 결제됨`;
  }
  // 이 지점에서 order: PendingOrder | CancelledOrder
  return `상태: ${order.status}`;
}

console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "품절" }));
2026-10-01T00:00:00.000Z에 결제됨
상태: cancelled

설명:

  • order is PaidOrder는 함수가 true를 반환하면 order가 PaidOrder 타입이라는 뜻입니다.
  • 런타임에는 함수가 여전히 true/false만 반환합니다. is 부분은 컴파일 타임에만 존재합니다.
  • false 브랜치에서는 TypeScript가 union에서 PaidOrder를 제외합니다. order는 PendingOrder | CancelledOrder가 됩니다.
  • predicate의 파라미터 이름(order)은 함수의 파라미터 이름 중 하나와 정확히 같아야 합니다.

3.2. Type guard로 배열 필터링

Type guard는 Array.prototype.filter와 특히 잘 어울립니다. filter가 predicate를 받는 overload가 있기 때문입니다:

const orders: Order[] = [
  { status: "pending", id: "A1", total: 50 },
  { status: "paid", id: "A2", total: 120, paidAt: new Date() },
  { status: "paid", id: "A3", total: 80, paidAt: new Date() },
];

const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);

console.log(revenue);
200

paidOrders의 타입은 Order[]가 아니라 PaidOrder[]입니다. 그 덕분에 이후의 모든 연산에서 paidAt에 접근할 수 있고 타입 단언이 필요 없습니다.

TypeScript 5.5부터는 컴파일러가 단순한 arrow function에 대해 type predicate를 자동으로 추론합니다. 예를 들어 orders.filter(o => o.status === "paid")도 PaidOrder[]를 반환합니다. 이 추론은 표현식이 충분히 단순하고 “true ⇔ 해당 타입에 속함”의 관계가 정확할 때만 작동합니다. 더 복잡한 로직에서는 여전히 명시적 type guard를 작성해야 합니다.

3.3. unknown 데이터 검증

Type guard의 가장 실용적인 사용 사례는 API의 JSON처럼 외부 데이터를 검증하는 것입니다:

interface Customer {
  id: string;
  name: string;
  email: string;
}

function isCustomer(value: unknown): value is Customer {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "string" &&
    "name" in value && typeof value.name === "string" &&
    "email" in value && typeof value.email === "string"
  );
}

const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');

if (isCustomer(raw)) {
  console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM

TypeScript 4.9부터 in 연산자가 value를 object & Record<"id", unknown>으로 좁혀줍니다. 따라서 as any 없이 value.id를 작성할 수 있습니다. 각 필드는 존재 여부와 타입 모두 검사됩니다.

3.4. 함정: type guard의 거짓말

컴파일러는 type predicate를 절대적으로 신뢰합니다. 함수 본체가 약속과 실제로 일치하는지는 검사하지 않습니다:

function isCustomerBroken(value: unknown): value is Customer {
  return typeof value === "object" && value !== null; // 필드들 검사를 빠뜨림
}

const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
  console.log(data.email.toUpperCase()); // 컴파일은 OK, 런타임에서 터짐
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

더 교활한 함정이 false 브랜치에 있습니다:

function isLargeAmount(value: string | number): value is number {
  return typeof value === "number" && value > 1000;
}

function format(value: string | number) {
  if (isLargeAmount(value)) return `큼: ${value}`;
  return value.toUpperCase(); // TS는 value: string이라고 생각함
}

format(5); // TypeError: value.toUpperCase is not a function

함수가 숫자 5에 대해 false를 반환합니다. TypeScript는 나머지 브랜치에서 value가 number가 아니라고 추론합니다. 이건 잘못됨입니다.

주의: Type predicate는 양방향 관계입니다. true를 반환하는 것은 “타입 T에 속한다”는 뜻이어야 합니다. false를 반환하는 것은 “타입 T에 속하지 않는다”는 뜻이어야 합니다. type guard에 비즈니스 로직 조건(예: > 1000)을 섞지 마세요. 이들을 별도의 boolean 함수로 분리하세요.

4. Assertion function

4.1. asserts value is T

Type guard를 사용하면 코드를 if로 감싸야 합니다. “틀리면 에러를 던지고, 아니면 계속 실행”하는 동작을 원할 때가 있습니다. 이것이 assertion function의 역할입니다:

function assertIsCustomer(value: unknown): asserts value is Customer {
  if (!isCustomer(value)) {
    throw new TypeError("유효한 Customer 데이터가 아닙니다");
  }
}

function greet(payload: unknown): string {
  assertIsCustomer(payload);
  // 이 줄부터 payload: Customer
  return `안녕하세요 ${payload.name}님`;
}

console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
안녕하세요 Minh님
TypeError: 유효한 Customer 데이터가 아닙니다

Type guard와의 차이점:

  • 반환 타입이 asserts value is Customer입니다. 함수 본체는 어떤 값도 return하지 않으므로 함수는 void를 반환합니다.
  • 함수는 조건이 거짓일 때 에러를 던집니다. 함수가 정상적으로 반환되면 TypeScript는 조건이 참이라고 간주합니다.
  • 타입이 호출 이후의 전체 코드에서 좁혀집니다. 단일 if 블록 내만이 아닙니다.

4.2. asserts condition

두 번째 형식은 특정 타입과 연결되지 않습니다. Node.js의 assert처럼 표현식이 truthy임을 단언합니다:

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function applyDiscount(order: Order, percent: number): number {
  assert(order.status !== "cancelled", `주문 ${order.id}은(는) 이미 취소되었습니다`);
  // order: PendingOrder | PaidOrder → total 필드 있음
  assert(percent > 0 && percent <= 50, "할인율은 (0, 50]에 있어야 합니다");
  return order.total * (1 - percent / 100);
}

console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180

assert(order.status !== "cancelled", ...)를 지나면 TypeScript는 if를 작성한 것처럼 order를 좁혀줍니다. 따라서 CancelledOrder에 total 필드가 없어도 order.total에 접근할 수 있습니다.

4.3. 오류 TS2775: 명시적 타입 annotation 누락

Assertion function을 arrow function으로 작성할 때 가장 자주 마주하는 오류입니다:

const assertPositive = (n: number): asserts n => {
  if (n <= 0) throw new Error("양수여야 합니다");
};

assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.

컴파일러는 선언 시점에 assertPositive의 타입을 알아야 하며, 추론에 의존할 수 없습니다. 두 가지 수정 방법이 있습니다. 첫 번째는 function 선언을 사용하는 것입니다. 두 번째는 변수에 타입을 명시하는 것입니다:

type AssertPositive = (n: number) => asserts n;

const assertPositive: AssertPositive = (n) => {
  if (n <= 0) throw new Error("양수여야 합니다");
};

이 규칙은 객체를 통해 호출되는 메서드에도 적용됩니다. 예를 들어 validator.assertCustomer(x)의 경우입니다. validator 변수는 명시적 타입 선언이 있어야 합니다. 예를 들어 const validator: CustomerValidator = new CustomerValidator()처럼요.

주의: Assertion function은 async일 수 없습니다. Promise<asserts x is T> 타입은 존재하지 않습니다. 비동기 검증이 필요하면 먼저 await로 데이터를 가져온 다음, 그 결과에 대해 동기 assertion function을 호출하세요.

5. Type guard와 assertion function 테스트 작성

이것들은 런타임 로직이므로 다른 함수처럼 테스트해야 합니다. 아래 예제는 Vitest를 사용합니다:

npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";

describe("isCustomer", () => {
  it("유효한 객체에 대해 true를 반환", () => {
    expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
  });

  it.each([
    null,
    "C1",
    { id: "C1", name: "Lan" },
    { id: 1, name: "Lan", email: "lan@example.com" },
  ])("%j에 대해 false를 반환", (input) => {
    expect(isCustomer(input)).toBe(false);
  });
});

describe("assertIsCustomer", () => {
  it("잘못된 데이터에 대해 TypeError를 던짐", () => {
    expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
  });

  it("유효한 데이터에 대해 타입을 좁혀줌", () => {
    const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
    assertIsCustomer(payload);
    expect(payload.email).toBe("lan@example.com"); // narrowing 덕분에 컴파일됨
  });
});
npx vitest run
 ✓ customer.test.ts (7 tests)
 Test Files  1 passed (1)
      Tests  7 passed (7)

음수 케이스에 주목하세요: 필드 누락, 잘못된 타입, null, 원시 값입니다. Type guard의 대부분의 버그는 3.4절 예제처럼 false를 반환해야 할 때 true를 반환하는 경우에 있습니다. 마지막 테스트는 컴파일 타임 테스트의 역할도 합니다. 누군가 함수 시그니처를 void로 바꾸면 테스트 파일이 컴파일되지 않을 것입니다.

6. Type guard인가 assertion function인가?

상황 사용하기
잘못된 데이터가 일반적이고 다양한 처리가 필요함 Type guard
배열 필터링, find, filter와 함께 사용 Type guard
잘못된 데이터가 버그이거나 계약 위반, 즉시 중단해야 함 Assertion function
함수 시작 검증(precondition), 중첩 if 회피 Assertion function

두 도구는 보통 함께 사용됩니다. Type guard를 핵심 로직으로 작성한 후 assertion function으로 감싸면, assertIsCustomer가 isCustomer를 호출하는 방식처럼 됩니다. 이렇게 하면 검증 로직이 한 곳에만 있습니다.

직접 작성하지 말아야 할 때:

  • 크거나 중첩된 스키마. 30개 필드가 있고 배열이 중첩된 객체에 대해 손으로 isCustomer를 작성하기는 쉽게 실수합니다. Zod나 Valibot 같은 검증 라이브러리는 동일한 스키마에서 타입과 검증을 함께 생성하므로 둘이 맞지 않을 수 없습니다.

  • as 대체품이 아님. 빈 본체의 type guard (return true)는 value as Customer보다 안전하지 않습니다. 단지 문제를 더 정교하게 숨기는 것뿐입니다.

  • 단순한 discriminated union. if (order.status === "paid")만으로도 충분하면 함수 만드느라고 분리하지 마세요.

7. 결론

Type guard(value is T)를 사용하면 타입 검사 로직을 함수로 캡슐화하면서도 호출 위치에서 narrowing을 유지할 수 있습니다. 특히 if와 filter와 잘 어울립니다. Assertion function(asserts value is T 또는 asserts condition)은 조건이 거짓일 때 에러를 던져 이후의 전체 코드에서 타입을 좁혀줍니다. Precondition과 입력 데이터 검증에 적합합니다.

둘 다 컴파일러가 검증하지 않는 약속입니다. 따라서 세 가지를 기억하세요:

  • “true ⇔ 해당 타입에 속함” 관계를 유지하세요.
  • Assertion function에는 명시적 타입 선언을 하세요.
  • 음수 케이스를 철저히 테스트하세요.
typescripttype guardassertion functionnarrowingtype safety