이번 편의 결과물: isTransaction, assertIsTransaction을 작성해 API 클라이언트가 받은 응답을 수동으로 검증하고, 필드가 늘어날수록 코드가 중복되는 문제를 직접 겪습니다. · 다루는 개념: 사용자 정의 타입 가드(is), asserts assertion 함수, 수동 검증의 한계
10편에서 만든 ApiClient의 get, 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으로 좁혀집니다. fundamentals의 isExpenseCategory, 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가 던지는 오류 메시지에 몇 번째 항목이 잘못됐는지 정확히 나타납니다.
직접 해보기
Transaction에tags?: string[](선택적 태그 목록) 필드가 새로 생겼다고 가정하고,isTransaction에 이 필드를 검사하는 줄을 추가해 봅니다. 배열인지, 배열 안 각 값이 문자열인지까지 확인하려면 몇 줄이 더 필요한지 세어 봅니다.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를 포함해 어떤 항목이 왜 실패했는지 메시지에 남긴다 |