Skip to Content
WebTypeScriptTypeScript06. 유니온 타입과 내로잉으로 카테고리 제한하기

이번 편의 결과물: 카테고리가 문자열 리터럴 유니온으로 제한되어, 필터·입력에서 잘못된 카테고리 값이 컴파일 타임에 막힙니다. · 다루는 개념: 유니온 타입, 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) }

isCategorytypeof 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를 먼저 검사합니다. RangeErrorError의 하위 클래스라서 순서를 바꾸면 RangeError 분기에 도달하지 못합니다. handleChangeevent.targetHTMLSelectElement인지 HTMLInputElement인지 instanceof로 구분해 서로 다른 필터 조건을 만듭니다. render()const filter = currentFilter로 먼저 지역 변수에 옮겨 담은 뒤 expenses.filter(...) 콜백에 넘깁니다. currentFilter를 콜백 안에서 바로 썼다면 let 변수라 좁혀진 타입이 유지되지 않아 오류가 났을 것입니다.

5. 실행

npm run dev

확인

  • 카테고리 select에는 정해진 5개 옵션만 있어 잘못된 값을 고를 수 없습니다.
  • “카테고리로 필터”에서 특정 카테고리를 고르면 해당 카테고리 거래만 보입니다.
  • “월로 필터”에서 월을 고르면 해당 월 거래만 보입니다.
  • 금액에 0을 입력하고 추가하면 “금액 오류: 금액은 0이 될 수 없습니다” 알림이 뜹니다.

직접 해보기

  1. Category'의료'를 추가하고, CATEGORIES 배열에도 반영해 select 옵션이 늘어나는지 확인하세요.
  2. 브라우저 개발자 도구 콘솔에서 isCategory(123), isCategory('식비')를 각각 실행해 반환값을 확인하세요.

정답 보기

export type Category = '식비' | '교통' | '문화' | '주거' | '의료' | '기타' export const CATEGORIES: readonly Category[] = ['식비', '교통', '문화', '주거', '의료', '기타']

isCategory(123)typeof 123 !== 'string'이라 바로 false를 반환합니다. isCategory('식비')는 문자열이면서 CATEGORIES에 포함돼 있어 true를 반환합니다.

자주 하는 실수

증상원인고치는 법
select 값을 Category에 그대로 대입하면 타입 오류select의 .value는 항상 stringisCategory로 좁힌 뒤에만 Category로 사용한다
let 변수를 좁힌 뒤 배열 콜백에서 쓰면 타입 오류콜백이 나중에 실행될 수 있어 좁힌 타입이 유지되지 않음좁힌 값을 const 지역 변수에 담아 콜백에 넘긴다
RangeError 메시지가 항상 일반 오류로 표시됨catch에서 ErrorRangeError보다 먼저 검사더 구체적인 클래스(RangeError)를 먼저 검사한다

확인 문제

문제 14지선다
category를 string 대신 리터럴 유니온으로 바꾼 이유는
문제 24지선다
isCategory에서 typeof 검사를 가장 먼저 하는 이유는
문제 34지선다
matchesFilter가 in 연산자로 확인하는 것은
문제 44지선다
render 안에서 currentFilter를 const filter에 옮겨 담은 뒤 배열 콜백에 넘기는 이유는

참고 자료

Last updated on