Skip to Content
WebTypeScriptTypeScript 중급12. 타입 가드와 assertion 함수 설계

이번 편의 결과물: isTransaction, assertIsTransaction을 작성해 API 클라이언트가 받은 응답을 수동으로 검증하고, 필드가 늘어날수록 코드가 중복되는 문제를 직접 겪습니다. · 다루는 개념: 사용자 정의 타입 가드(is), asserts assertion 함수, 수동 검증의 한계

10편에서 만든 ApiClientget, send는 응답 타입을 TRoutes[TRoute]['response']로 정확히 표시합니다. 하지만 이 타입은 컴파일 타임에만 존재합니다. 실제로 서버가(또는 15편에서 붙일 목업 서버가) 돌려주는 값이 정말 그 모양인지는 런타임에 아무도 확인하지 않습니다. 필드 이름이 바뀌었거나 값이 비어 있어도 as로 단언한 타입 그대로 믿고 넘어가게 됩니다. 이 편에서는 응답이 정말 Transaction 모양인지 실행 중에 직접 확인하는 함수를 만들고, 그 방식이 얼마나 번거로운지 확인합니다. 13편에서 zod로 같은 문제를 다시 풀어 이 번거로움과 비교합니다.

이 편에서 만드는 파일

expense-tracker/ ├── src/ │ └── lib/ │ └── api/ │ └── guards.ts + (isTransaction, assertIsTransaction, assertIsTransactionArray) └── tests/ └── guards.test.ts + (정상·비정상 데이터 검증)

개념 정리

사용자 정의 타입 가드

반환 타입을 value is Type 형태로 선언한 함수입니다. 함수가 true를 반환하면, 호출한 자리 이후로 그 값의 타입이 Type으로 좁혀집니다. fundamentalsisExpenseCategory, isIncomeCategory도 같은 형태였습니다. 이 편에서는 카테고리 하나가 아니라 객체 전체(Transaction)의 모양을 확인하는 가드를 만듭니다.

asserts assertion 함수

타입 가드는 if 문 안에서만 좁혀주지만, “이 값이 아니면 여기서 바로 멈춘다”처럼 확인 즉시 예외를 던지고 싶을 때는 asserts value is Type 형태의 assertion 함수를 씁니다. 이 함수는 반환값이 없고, 정상적으로 끝나면 그 이후 코드에서 값의 타입이 이미 좁혀진 것으로 간주됩니다.

function assertIsString(value: unknown): asserts value is string { if (typeof value !== 'string') { throw new TypeError('문자열이 아닙니다') } }

수동 검증의 한계

Transaction은 필드가 다섯 개(id, date, amount, memo, category)뿐이라 아직은 감당할 만합니다. 하지만 필드가 하나 늘어날 때마다 isTransaction 안에 검사 줄을 하나씩 더 써야 하고, Transaction 타입 정의와 isTransaction 함수 본문 두 곳을 항상 같이 고쳐야 합니다. 둘 중 하나만 고치고 잊으면, 타입은 필드를 요구하는데 가드는 그 필드를 확인하지 않는 상태로 어긋납니다.

실습

1. 가드 파일 만들기

expense-tracker/src/lib/api/guards.ts 파일을 추가합니다.

2. 코드 작성

// expense-tracker/src/lib/api/guards.ts import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../../models/expense.ts' import type { ExpenseCategory, IncomeCategory, Transaction } from '../../models/expense.ts' function isExpenseCategoryValue(value: unknown): value is ExpenseCategory { return typeof value === 'string' && (EXPENSE_CATEGORIES as readonly string[]).includes(value) } function isIncomeCategoryValue(value: unknown): value is IncomeCategory { return typeof value === 'string' && (INCOME_CATEGORIES as readonly string[]).includes(value) } export function isTransaction(value: unknown): value is Transaction { if (typeof value !== 'object' || value === null) { return false } const record = value as Record<string, unknown> if (typeof record.id !== 'string') return false if (typeof record.date !== 'string') return false if (typeof record.amount !== 'number') return false if (typeof record.memo !== 'string') return false if (record.type === 'expense') return isExpenseCategoryValue(record.category) if (record.type === 'income') return isIncomeCategoryValue(record.category) return false } export function assertIsTransaction(value: unknown): asserts value is Transaction { if (!isTransaction(value)) { throw new TypeError(`서버 응답이 거래 모양이 아닙니다: ${JSON.stringify(value)}`) } } export function assertIsTransactionArray(value: unknown): asserts value is Transaction[] { if (!Array.isArray(value)) { throw new TypeError('서버 응답이 배열이 아닙니다') } value.forEach((item, index) => { if (!isTransaction(item)) { throw new TypeError(`${index}번째 항목이 거래 모양이 아닙니다: ${JSON.stringify(item)}`) } }) }

id는 07편에서 TransactionId라는 브랜디드 타입으로 바뀌었지만, 브랜드는 컴파일 타임 표시일 뿐 런타임에는 여전히 평범한 문자열입니다. 그래서 typeof record.id === 'string' 검사만으로 충분합니다. record.type으로 먼저 갈래를 나누고, 갈래에 맞는 카테고리 가드를 호출하는 순서는 07편의 isExpenseCategory/isIncomeCategory 분리와 같은 이유입니다 — type과 무관하게 하나의 가드로만 검사하면 수입에 지출 카테고리가 섞여도 걸러내지 못합니다.

ApiClient가 받은 응답을 실제로 검증하는 흐름을 테스트로 확인합니다.

// expense-tracker/tests/guards.test.ts import { describe, expect, it } from 'vitest' import { assertIsTransaction, assertIsTransactionArray, isTransaction } from '../src/lib/api/guards.ts' const validExpense = { id: 't-1', type: 'expense', date: '2026-09-01', category: 'food', amount: 12000, memo: '점심', } describe('isTransaction', () => { it('필드를 모두 갖춘 지출 거래를 통과시킨다', () => { expect(isTransaction(validExpense)).toBe(true) }) it('카테고리가 type과 어긋나면 거부한다', () => { expect(isTransaction({ ...validExpense, type: 'income' })).toBe(false) }) it('필드가 빠지면 거부한다', () => { const { memo: _memo, ...withoutMemo } = validExpense expect(isTransaction(withoutMemo)).toBe(false) }) }) describe('assertIsTransaction', () => { it('올바른 값은 예외를 던지지 않는다', () => { expect(() => assertIsTransaction(validExpense)).not.toThrow() }) it('잘못된 값은 명확한 메시지와 함께 예외를 던진다', () => { expect(() => assertIsTransaction({ id: 't-2' })).toThrow('서버 응답이 거래 모양이 아닙니다') }) }) describe('assertIsTransactionArray', () => { it('배열 안 항목 하나가 잘못되면 그 인덱스를 알려주며 예외를 던진다', () => { expect(() => assertIsTransactionArray([validExpense, { id: 't-3' }])).toThrow('1번째 항목이') }) })

3. 실행

npx vitest run tests/guards.test.ts
✓ tests/guards.test.ts (6) ✓ isTransaction > 필드를 모두 갖춘 지출 거래를 통과시킨다 ✓ isTransaction > 카테고리가 type과 어긋나면 거부한다 ✓ isTransaction > 필드가 빠지면 거부한다 ✓ assertIsTransaction > 올바른 값은 예외를 던지지 않는다 ✓ assertIsTransaction > 잘못된 값은 명확한 메시지와 함께 예외를 던진다 ✓ assertIsTransactionArray > 배열 안 항목 하나가 잘못되면 그 인덱스를 알려주며 예외를 던진다

확인

  • 여섯 테스트가 모두 통과합니다.
  • assertIsTransaction을 통과한 값은 그 아래 코드에서 별도 단언 없이 Transaction의 프로퍼티(value.category 등)에 곧바로 접근할 수 있습니다.
  • assertIsTransactionArray가 던지는 오류 메시지에 몇 번째 항목이 잘못됐는지 정확히 나타납니다.

직접 해보기

  1. Transactiontags?: string[](선택적 태그 목록) 필드가 새로 생겼다고 가정하고, isTransaction에 이 필드를 검사하는 줄을 추가해 봅니다. 배열인지, 배열 안 각 값이 문자열인지까지 확인하려면 몇 줄이 더 필요한지 세어 봅니다.
  2. assertIsTransaction({ ...validExpense, amount: '12000' })(금액이 문자열)을 호출해 보고 예외 메시지를 확인합니다.

정답 보기

1번은 다음과 같은 검사가 추가로 필요합니다.

if (record.tags !== undefined) { if (!Array.isArray(record.tags)) return false if (!record.tags.every((tag) => typeof tag === 'string')) return false }

필드 하나(그것도 선택적 필드)를 추가했을 뿐인데 검사 줄이 세 줄 더 늘었습니다. Transaction에 필드를 추가할 때마다 이 함수를 잊지 않고 같이 고쳐야 하는 부담이 여기서 드러납니다.

2번은 record.amount가 문자열이라 typeof record.amount !== 'number' 검사에 걸려 assertIsTransaction이 “서버 응답이 거래 모양이 아닙니다”를 던집니다.

자주 하는 실수

증상원인고치는 법
타입은 필드를 요구하는데 가드가 그 필드를 확인 안 함Transaction 정의를 고치고 isTransaction은 그대로 둠모델 필드를 바꿀 때마다 관련 가드 함수도 함께 검토한다
assert 함수인데 값을 반환하려고 함일반 타입 가드와 assertion 함수를 혼동함asserts 함수는 반환 타입이 없고, 실패하면 예외만 던진다
배열 검증에서 잘못된 항목의 위치를 알 수 없음forEach 없이 every로만 확인해 어느 항목이 문제인지 정보가 사라짐index를 포함해 어떤 항목이 왜 실패했는지 메시지에 남긴다

확인 문제

문제 14지선다
value is Type 형태로 반환 타입을 선언한 함수를 부르는 이름은
문제 24지선다
asserts value is Type 형태의 assertion 함수와 일반 타입 가드의 차이는
문제 34지선다
isTransaction에서 id 필드를 typeof record.id === string으로만 확인해도 되는 이유는
문제 44지선다
이 편에서 확인한 수동 타입 가드 방식의 근본적인 한계는

참고 자료

Last updated on