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에는 명시적 타입 선언을 하세요.
- 음수 케이스를 철저히 테스트하세요.