이번 편의 결과물: 카테고리가 문자열 리터럴 유니온으로 제한되어, 필터·입력에서 잘못된 카테고리 값이 컴파일 타임에 막힙니다. · 다루는 개념: 유니온 타입, typeof 내로잉, in 내로잉, instanceof 내로잉
05편에서 Expense는 interface로 바뀌었고, 화면 폼으로 거래를 추가할 수 있게 됐습니다. 다만 category는 여전히 string이라 아무 문자열이나 들어갈 수 있습니다. 이 편에서는 category를 정해진 값만 허용하는 리터럴 유니온으로 제한하고, 폼·필터에서 들어오는 값을 안전하게 좁히는 방법을 다룹니다.
이 편에서 만드는 파일
expense-tracker/src/
├── models/
│ └── expense.ts (~, CATEGORIES를 Category 리터럴 유니온 배열로 변경)
├── services/
│ └── expenseStore.ts (~, NewExpenseInput.category를 Category로 좁힘)
├── lib/
│ └── parsing.ts (+, isCategory·parseAmount·matchesFilter)
└── main.ts (~, 카테고리 검증·필터·에러 처리 연동)개념 정리
유니온 타입
string 대신 실제로 허용할 값만 나열한 리터럴 유니온('식비' | '교통' | ...)을 쓰면, 그 값들 밖의 문자열은 대입 시점에 오류가 됩니다.
내로잉(narrowing)
값의 타입이 여러 가지일 수 있을 때, 조건문으로 실제 타입을 좁혀가는 과정입니다.
| 방법 | 언제 쓰는가 |
|---|---|
typeof | 원시 타입(문자열, 숫자 등)과 다른 타입이 섞인 유니온을 구분할 때 |
in | 서로 다른 프로퍼티를 가진 객체 유니온을 구분할 때 |
instanceof | 클래스·DOM 요소 인스턴스를 구분할 때 |
폼 데이터를 읽는 FormData.get()은 string이나 File, 또는 null을 반환하는 유니온 타입(FormDataEntryValue | null)을 돌려줍니다. 카테고리·금액 값을 실제로 쓰려면 먼저 typeof value === 'string'으로 좁혀야 합니다.
let 변수와 콜백 안에서의 내로잉
let 변수를 좁힌 뒤 배열 메서드의 콜백 함수 안에서 그대로 쓰면, TypeScript는 좁혀진 타입을 유지해주지 않습니다. 콜백이 나중에 실행되며 값이 바뀔 수 있기 때문입니다. 좁힌 값을 const 지역 변수에 옮겨 담아 콜백에 넘기면 해결됩니다.
실습
1. 모델에 Category 리터럴 유니온 추가
// expense-tracker/src/models/expense.ts
export const CURRENCY: 'KRW' = 'KRW'
export type Category = '식비' | '교통' | '문화' | '주거' | '기타'
export const CATEGORIES: readonly Category[] = ['식비', '교통', '문화', '주거', '기타']
export interface BaseRecord {
readonly id: number
date: string
}
export interface Expense extends BaseRecord {
category: Category
amount: number
memo: string
}2. expenseStore.ts의 입력 타입을 Category로 좁히기
// expense-tracker/src/services/expenseStore.ts
import { Expense, Category } from '../models/expense.ts'
const expenses: Expense[] = []
let nextId: number = 1
type NewExpenseInput = {
date: string
amount: number
category?: Category
memo?: string
}
export function addExpense({ date, amount, category = '기타', memo = '' }: NewExpenseInput): Expense {
const expense: Expense = { id: nextId, date, amount, category, memo }
expenses.push(expense)
nextId += 1
return expense
}
export function getExpenses(): Expense[] {
return expenses
}3. 검증·필터 유틸 작성
// expense-tracker/src/lib/parsing.ts
import { Category, CATEGORIES } from '../models/expense.ts'
export function isCategory(value: unknown): value is Category {
if (typeof value !== 'string') {
return false
}
return (CATEGORIES as readonly string[]).includes(value)
}
export function parseAmount(value: FormDataEntryValue | null): number {
if (typeof value !== 'string') {
throw new Error('금액 입력값이 없습니다')
}
const amount = Number(value)
if (Number.isNaN(amount)) {
throw new Error(`숫자로 변환할 수 없습니다: ${value}`)
}
if (amount === 0) {
throw new RangeError('금액은 0이 될 수 없습니다')
}
return amount
}
export type ExpenseFilter = { category: Category } | { month: string }
export function matchesFilter(expense: { date: string; category: Category }, filter: ExpenseFilter): boolean {
if ('category' in filter) {
return expense.category === filter.category
}
return expense.date.startsWith(filter.month)
}isCategory는 typeof value !== 'string'으로 먼저 문자열이 아닌 값을 걸러낸 다음에야 배열 포함 여부를 검사합니다. 문자열이 아닌 값에 includes를 쓰면 타입 오류가 나기 때문입니다. matchesFilter는 'category' in filter로 두 가지 모양({ category } 또는 { month }) 중 어느 쪽인지 좁혀서 서로 다른 방식으로 비교합니다.
4. main.ts에 카테고리 select와 필터, 에러 처리 연동
// expense-tracker/src/main.ts
import './style.css'
import { CATEGORIES, CURRENCY, Expense } from './models/expense.ts'
import { addExpense, getExpenses } from './services/expenseStore.ts'
import { isCategory, parseAmount, matchesFilter, ExpenseFilter } from './lib/parsing.ts'
addExpense({ date: '2026-09-18', amount: 12000, category: '식비', memo: '점심 식사' })
addExpense({ date: '2026-09-19', amount: 4500, category: '교통' })
addExpense({ date: '2026-09-20', amount: 30000 })
let currentFilter: ExpenseFilter | null = null
function renderExpenseList(list: Expense[]): string {
return list
.map((expense) => {
const amountText = `${expense.amount.toLocaleString()}${CURRENCY}`
const memoText = expense.memo || '메모 없음'
return `<li>#${expense.id} · ${expense.date} · ${expense.category} · ${amountText} · ${memoText}</li>`
})
.join('')
}
function renderCategoryOptions(): string {
return CATEGORIES.map((category) => `<option value="${category}">${category}</option>`).join('')
}
function renderFilterCategoryOptions(): string {
const selected = currentFilter !== null && 'category' in currentFilter ? currentFilter.category : ''
const options = CATEGORIES.map((category) => {
const attr = category === selected ? ' selected' : ''
return `<option value="${category}"${attr}>${category}</option>`
}).join('')
return `<option value="">전체</option>${options}`
}
function currentFilterMonth(): string {
return currentFilter !== null && 'month' in currentFilter ? currentFilter.month : ''
}
function render(): void {
const app = document.querySelector<HTMLDivElement>('#app')
if (!app) return
const filter = currentFilter
const expenses = getExpenses()
const filtered = filter === null ? expenses : expenses.filter((expense) => matchesFilter(expense, filter))
app.innerHTML = `
<main>
<h1>가계부</h1>
<form id="expense-form">
<label for="date">날짜</label>
<input id="date" name="date" type="date" required />
<label for="category">카테고리</label>
<select id="category" name="category">${renderCategoryOptions()}</select>
<label for="amount">금액</label>
<input id="amount" name="amount" type="number" required />
<label for="memo">메모</label>
<input id="memo" name="memo" type="text" />
<button type="submit">추가</button>
</form>
<section>
<label for="filter-category">카테고리로 필터</label>
<select id="filter-category">${renderFilterCategoryOptions()}</select>
<label for="filter-month">월로 필터</label>
<input id="filter-month" type="month" value="${currentFilterMonth()}" />
</section>
<ul>${renderExpenseList(filtered)}</ul>
</main>
`
}
function handleSubmit(event: SubmitEvent): void {
event.preventDefault()
const form = event.target as HTMLFormElement
const formData = new FormData(form)
const category = formData.get('category')
if (!isCategory(category)) {
alert(`알 수 없는 카테고리입니다: ${String(category)}`)
return
}
try {
const amount = parseAmount(formData.get('amount'))
addExpense({ date: String(formData.get('date')), amount, category, memo: String(formData.get('memo')) })
render()
} catch (error) {
if (error instanceof RangeError) {
alert(`금액 오류: ${error.message}`)
} else if (error instanceof Error) {
alert(`입력 오류: ${error.message}`)
}
}
}
function handleChange(event: Event): void {
const target = event.target
if (target instanceof HTMLSelectElement && target.id === 'filter-category') {
currentFilter = isCategory(target.value) ? { category: target.value } : null
render()
return
}
if (target instanceof HTMLInputElement && target.id === 'filter-month') {
currentFilter = target.value ? { month: target.value } : null
render()
}
}
const app = document.querySelector<HTMLDivElement>('#app')
app?.addEventListener('submit', handleSubmit)
app?.addEventListener('change', handleChange)
render()catch 블록은 instanceof RangeError를 먼저 검사합니다. RangeError도 Error의 하위 클래스라서 순서를 바꾸면 RangeError 분기에 도달하지 못합니다. handleChange는 event.target이 HTMLSelectElement인지 HTMLInputElement인지 instanceof로 구분해 서로 다른 필터 조건을 만듭니다. render()는 const filter = currentFilter로 먼저 지역 변수에 옮겨 담은 뒤 expenses.filter(...) 콜백에 넘깁니다. currentFilter를 콜백 안에서 바로 썼다면 let 변수라 좁혀진 타입이 유지되지 않아 오류가 났을 것입니다.
5. 실행
npm run dev확인
- 카테고리 select에는 정해진 5개 옵션만 있어 잘못된 값을 고를 수 없습니다.
- “카테고리로 필터”에서 특정 카테고리를 고르면 해당 카테고리 거래만 보입니다.
- “월로 필터”에서 월을 고르면 해당 월 거래만 보입니다.
- 금액에 0을 입력하고 추가하면 “금액 오류: 금액은 0이 될 수 없습니다” 알림이 뜹니다.
직접 해보기
Category에'의료'를 추가하고,CATEGORIES배열에도 반영해 select 옵션이 늘어나는지 확인하세요.- 브라우저 개발자 도구 콘솔에서
isCategory(123),isCategory('식비')를 각각 실행해 반환값을 확인하세요.
정답 보기
export type Category = '식비' | '교통' | '문화' | '주거' | '의료' | '기타'
export const CATEGORIES: readonly Category[] = ['식비', '교통', '문화', '주거', '의료', '기타']isCategory(123)은 typeof 123 !== 'string'이라 바로 false를 반환합니다. isCategory('식비')는 문자열이면서 CATEGORIES에 포함돼 있어 true를 반환합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| select 값을 Category에 그대로 대입하면 타입 오류 | select의 .value는 항상 string | isCategory로 좁힌 뒤에만 Category로 사용한다 |
let 변수를 좁힌 뒤 배열 콜백에서 쓰면 타입 오류 | 콜백이 나중에 실행될 수 있어 좁힌 타입이 유지되지 않음 | 좁힌 값을 const 지역 변수에 담아 콜백에 넘긴다 |
| RangeError 메시지가 항상 일반 오류로 표시됨 | catch에서 Error를 RangeError보다 먼저 검사 | 더 구체적인 클래스(RangeError)를 먼저 검사한다 |