이번 편의 결과물: sortBy, filterBy 제네릭 유틸 함수를 작성해 거래 목록 정렬·필터에 적용합니다. · 다루는 개념: 제네릭 함수, extends 제약, 기본 타입 매개변수, 제네릭 타입 추론
07편에서 거래는 Transaction 판별 유니온으로 재모델링됐고, 카테고리·월로 필터링하는 기능까지 갖췄습니다. 이 편에서는 필터·정렬처럼 어떤 데이터 목록에도 쓸 수 있는 동작을 Transaction에 매여 있지 않은 제네릭 함수로 뽑아내고, 정렬 기능을 새로 추가합니다.
이 편에서 만드는 파일
expense-tracker/src/
├── lib/
│ └── arrayUtils.ts (+, sortBy<T, K>, filterBy<T>)
└── main.ts (~, sortBy·filterBy로 정렬·필터 로직 교체, 정렬 select 추가)개념 정리
제네릭 함수
함수가 다루는 타입을 호출 시점까지 미뤄두고 타입 매개변수로 표시하는 방법입니다. Transaction[]에만 동작하는 함수 대신 T[]를 받는 함수를 쓰면, 나중에 다른 목록(예: 카테고리 목록)에도 그대로 재사용할 수 있습니다.
function firstOf<T>(list: T[]): T | undefined {
return list[0]
}extends 제약
타입 매개변수가 아무 타입이나 되면 곤란한 경우가 있습니다. “목록의 실제 키 중 하나”만 받고 싶다면 keyof T로 제약합니다.
function pickKey<T, K extends keyof T>(item: T, key: K): T[K] {
return item[key]
}K extends keyof T는 K가 T의 키 이름 중 하나여야 한다는 뜻입니다. 존재하지 않는 프로퍼티 이름을 넘기면 그 자리에서 오류가 납니다.
기본 타입 매개변수
타입 인자를 생략했을 때 쓸 기본값을 지정할 수 있습니다. 인자로부터 타입을 추론할 근거가 부족할 때(예: 빈 배열만 넘길 때) 유용합니다.
function filterBy<T = Transaction>(list: readonly T[], predicate: (item: T) => boolean): T[] {
return list.filter(predicate)
}
filterBy([], () => true) // 추론 근거가 부족해 기본값 Transaction이 쓰인다제네릭 타입 추론
호출할 때 타입 인자를 직접 쓰지 않아도, 넘긴 인자로부터 TypeScript가 알아서 타입 매개변수를 결정합니다. sortBy(transactions, 'date')처럼 쓰면 T는 Transaction으로, K는 'date'로 자동 추론됩니다.
실습
1. 제네릭 유틸 함수 작성
// expense-tracker/src/lib/arrayUtils.ts
import { Transaction } from '../models/expense.ts'
type SortableValue = string | number
export function sortBy<T, K extends keyof T>(list: readonly T[], key: K): T[] {
return [...list].sort((a, b) => {
const left = a[key] as unknown as SortableValue
const right = b[key] as unknown as SortableValue
if (left === right) return 0
return left < right ? -1 : 1
})
}
export function filterBy<T = Transaction>(list: readonly T[], predicate: (item: T) => boolean): T[] {
return list.filter(predicate)
}a[key], b[key]의 타입은 T[K]인데, K가 어떤 키인지 함수 몸체 안에서는 확정되지 않아 비교 연산자를 바로 쓸 수 없습니다. 실제로 정렬 가능한 값(문자열 또는 숫자)이라는 것을 알고 있으므로 as unknown as SortableValue로 타입을 명시합니다.
2. main.ts에 sortBy·filterBy와 정렬 select 적용
// expense-tracker/src/main.ts
import './style.css'
import { CURRENCY, EXPENSE_CATEGORIES, INCOME_CATEGORIES, Transaction } from './models/expense.ts'
import { addTransaction, getTransactions } from './services/expenseStore.ts'
import { isExpenseCategory, isIncomeCategory, parseAmount, matchesFilter, TransactionFilter } from './lib/parsing.ts'
import { toSignedAmount, toTypeLabel } from './lib/signedAmount.ts'
import { sortBy, filterBy } from './lib/arrayUtils.ts'
type SortKey = 'date' | 'amount'
addTransaction({ type: 'income', date: '2026-09-01', category: 'salary', amount: 3000000, memo: '9월 급여' })
addTransaction({ type: 'expense', date: '2026-09-03', category: 'food', amount: 18000, memo: '점심' })
addTransaction({ type: 'expense', date: '2026-09-10', category: 'transport', amount: 4500, memo: '버스' })
let currentFilter: TransactionFilter | null = null
let currentSortKey: SortKey = 'date'
function renderTransactionList(list: Transaction[]): string {
return list
.map((transaction) => {
const amountText = `${transaction.amount.toLocaleString()}${CURRENCY}`
const memoText = transaction.memo || '메모 없음'
return `<li>[${toTypeLabel(transaction)}] ${transaction.date} · ${transaction.category} · ${amountText} · ${memoText}</li>`
})
.join('')
}
function renderCategoryOptions(): string {
const incomeOptions = INCOME_CATEGORIES.map((category) => `<option value="${category}">${category}(수입)</option>`).join('')
const expenseOptions = EXPENSE_CATEGORIES.map((category) => `<option value="${category}">${category}(지출)</option>`).join('')
return incomeOptions + expenseOptions
}
function renderFilterCategoryOptions(): string {
const selected = currentFilter !== null && 'category' in currentFilter ? currentFilter.category : ''
const all = [...INCOME_CATEGORIES, ...EXPENSE_CATEGORIES]
const unique = all.filter((category, index) => all.indexOf(category) === index)
const options = unique
.map((category) => `<option value="${category}"${category === selected ? ' selected' : ''}>${category}</option>`)
.join('')
return `<option value="">전체</option>${options}`
}
function currentFilterMonth(): string {
return currentFilter !== null && 'month' in currentFilter ? currentFilter.month : ''
}
function renderSortOptions(): string {
const options: { key: SortKey; label: string }[] = [
{ key: 'date', label: '날짜순' },
{ key: 'amount', label: '금액순' },
]
return options
.map((option) => `<option value="${option.key}"${option.key === currentSortKey ? ' selected' : ''}>${option.label}</option>`)
.join('')
}
function renderTotal(list: Transaction[]): string {
const total = list.reduce((sum, transaction) => sum + toSignedAmount(transaction), 0)
return `${total.toLocaleString()}${CURRENCY}`
}
function render(): void {
const app = document.querySelector<HTMLDivElement>('#app')
if (!app) return
const filter = currentFilter
const transactions = getTransactions()
const filtered =
filter === null ? transactions : filterBy(transactions, (transaction) => matchesFilter(transaction, filter))
const sorted = sortBy(filtered, currentSortKey)
app.innerHTML = `
<main>
<h1>가계부</h1>
<form id="expense-form">
<fieldset>
<legend>구분</legend>
<label><input type="radio" name="type" value="income" /> 수입</label>
<label><input type="radio" name="type" value="expense" checked /> 지출</label>
</fieldset>
<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" min="1" 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()}" />
<label for="sort-key">정렬 기준</label>
<select id="sort-key">${renderSortOptions()}</select>
</section>
<p>합계: <span id="total-amount">${renderTotal(sorted)}</span></p>
<ul>${renderTransactionList(sorted)}</ul>
</main>
`
}
function handleSubmit(event: SubmitEvent): void {
event.preventDefault()
const form = event.target as HTMLFormElement
const formData = new FormData(form)
const type = formData.get('type')
const category = formData.get('category')
const date = String(formData.get('date'))
const memo = String(formData.get('memo'))
try {
const amount = parseAmount(formData.get('amount'))
if (type === 'income') {
if (!isIncomeCategory(category)) {
alert(`수입 카테고리가 아닙니다: ${String(category)}`)
return
}
addTransaction({ type: 'income', date, category, amount, memo })
} else {
if (!isExpenseCategory(category)) {
alert(`지출 카테고리가 아닙니다: ${String(category)}`)
return
}
addTransaction({ type: 'expense', date, category, amount, memo })
}
render()
} catch (error) {
if (error instanceof Error) {
alert(`입력 오류: ${error.message}`)
}
}
}
function isSortKey(value: string): value is SortKey {
return value === 'date' || value === 'amount'
}
function handleChange(event: Event): void {
const target = event.target
if (target instanceof HTMLSelectElement && target.id === 'filter-category') {
currentFilter = target.value ? { category: target.value } : null
render()
return
}
if (target instanceof HTMLInputElement && target.id === 'filter-month') {
currentFilter = target.value ? { month: target.value } : null
render()
return
}
if (target instanceof HTMLSelectElement && target.id === 'sort-key' && isSortKey(target.value)) {
currentSortKey = target.value
render()
}
}
const app = document.querySelector<HTMLDivElement>('#app')
app?.addEventListener('submit', handleSubmit)
app?.addEventListener('change', handleChange)
render()sortBy(filtered, currentSortKey)를 호출할 때 타입 인자를 직접 쓰지 않아도, 첫 인자로 T가 Transaction, currentSortKey의 타입으로 K가 SortKey로 추론됩니다. SortKey('date' | 'amount')는 keyof Transaction의 부분집합이라 제약을 만족합니다. filterBy도 transactions에서 T가 추론되어, 기본값 Transaction은 이번 호출에서는 쓰이지 않습니다.
3. 실행
npm run dev확인
- “정렬 기준”에서 “금액순”을 고르면 목록이 금액 오름차순으로 다시 그려집니다.
- “정렬 기준”에서 “날짜순”을 고르면 목록이 날짜 오름차순으로 돌아옵니다.
- 카테고리·월 필터와 정렬을 함께 적용해도 정상 동작합니다.
직접 해보기
sortBy에direction: 'asc' | 'desc' = 'asc'매개변수를 추가해, 내림차순 정렬도 지원해 보세요.- 브라우저 개발자 도구 콘솔에서
filterBy(getTransactions(), (t) => t.amount > 10000)처럼 금액이 10000을 넘는 거래만 걸러내는 호출을 실행해 보세요.
정답 보기
export function sortBy<T, K extends keyof T>(
list: readonly T[],
key: K,
direction: 'asc' | 'desc' = 'asc',
): T[] {
return [...list].sort((a, b) => {
const left = a[key] as unknown as SortableValue
const right = b[key] as unknown as SortableValue
if (left === right) return 0
const result = left < right ? -1 : 1
return direction === 'asc' ? result : -result
})
}direction은 타입 매개변수가 아니라 일반 함수 매개변수의 기본값입니다. 타입 인자의 기본값(K = ... 같은 형태)과는 다른 개념이니 헷갈리지 않도록 주의합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 비교 연산자를 제네릭 값에 적용할 수 없다는 오류 | 제네릭 값 a[key]를 바로 비교 연산자에 씀 | 실제로 비교 가능한 값이라는 것을 as unknown as SortableValue로 명시한다 |
sortBy(list, '아무이름') 호출이 오류남 | 넘긴 문자열이 keyof T에 없는 이름 | K extends keyof T 제약 덕분에 정확한 오류 위치를 바로 알 수 있다. 실제 존재하는 키로 고친다 |
filterBy([], predicate) 호출 결과 타입이 이상함 | 빈 배열이라 T를 추론할 근거가 없음 | 기본 타입 매개변수를 지정하거나 타입 인자를 직접 명시한다 |