Skip to Content
WebTypeScriptTypeScript 중급03. 조건부 타입과 infer로 타입 추출하기

이번 편의 결과물: lib/types/utility.tsElementOf, UnwrapPromise 조건부 타입을 작성하고, 새로 만드는 groupBy 유틸과 카테고리별 그룹핑 기능에 적용합니다. · 다루는 개념: 조건부 타입 문법(T extends U ? X : Y), infer로 타입 변수 추출, 분산 조건부 타입

이 편에서 만드는 파일

expense-tracker/src/ ├── lib/ │ ├── types/ │ │ └── utility.ts + (ElementOf, UnwrapPromise) │ └── arrayUtils.ts ~ (groupBy 추가) ├── services/ │ ├── stats.ts ~ (categoryGroups, fetchCategoryGroups 추가) │ └── index.ts ~ (fetchCategoryGroups, CategoryGroups 배럴 반영) └── ui/ └── statsPanel.ts ~ (카테고리별 거래 건수 표시 추가)

개념 정리

조건부 타입 기본 문법

T extends U ? X : Y는 “TU에 대입 가능하면 X, 아니면 Y”라는 뜻의 타입 수준 조건문입니다. 값의 if문과 모양은 비슷하지만, 실행되는 것이 아니라 컴파일 타임에 계산되어 하나의 타입으로 결정됩니다.

type IsString<T> = T extends string ? true : false type A = IsString<'hello'> // true type B = IsString<42> // false

infer로 타입 변수 추출하기

extends 절 안에서 infer 이름을 쓰면, 그 자리에 오는 실제 타입을 새 타입 변수로 뽑아내 ? 뒤에서 쓸 수 있습니다.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T type C = UnwrapPromise<Promise<number>> // number type D = UnwrapPromise<string> // string(Promise가 아니므로 그대로)

Promise<infer U>는 “이 타입이 Promise<무언가> 모양이라면, 그 무언가를 U라고 부르겠다”는 뜻입니다. 배열의 원소 타입을 뽑아낼 때도 같은 방식을 씁니다.

type ElementOf<T> = T extends readonly (infer U)[] ? U : never type E = ElementOf<string[]> // string type F = ElementOf<number> // never(배열이 아니므로)

분산 조건부 타입

조건부 타입의 T 자리에 유니온 타입을 그대로 전달하면, 유니온 전체가 한 번에 검사되지 않고 각 멤버마다 따로 검사된 뒤 결과가 다시 유니온으로 합쳐집니다. 이 동작을 분산 조건부 타입(distributive conditional type)이라고 부릅니다.

type ToArray<T> = T extends unknown ? T[] : never type G = ToArray<string | number> // string[] | number[]

T가 조건절 왼쪽에 그대로(다른 타입으로 감싸이지 않고) 나타나는 타입 매개변수일 때만 분산이 일어납니다. ElementOf<T>T도 조건절 왼쪽에 직접 나오므로, ElementOf<string[] | number[]>처럼 유니온을 넘기면 string | number로 각 배열 타입을 따로 처리한 결과가 합쳐집니다.

실습

1. 조건부 타입 유틸 작성

// expense-tracker/src/lib/types/utility.ts export type ElementOf<T> = T extends readonly (infer U)[] ? U : never export type UnwrapPromise<T> = T extends Promise<infer U> ? U : T

2. groupBy 유틸 추가

// expense-tracker/src/lib/arrayUtils.ts (파일 끝에 추가, sortBy·filterBy는 08편과 동일) export function groupBy<T, K extends string>(list: readonly T[], getKey: (item: T) => K): Record<K, T[]> { const result = {} as Record<K, T[]> for (const item of list) { const key = getKey(item) const bucket = result[key] if (bucket) { bucket.push(item) } else { result[key] = [item] } } return result }

groupBy는 목록을 getKey가 돌려주는 값 기준으로 묶습니다. 카테고리별로 묶든, 월별로 묶든 같은 함수를 재사용할 수 있습니다.

3. StatsCalculator에 categoryGroups 추가

// expense-tracker/src/services/stats.ts (발췌 — categoryTotals·monthlySummary·latestTransaction·clearCache는 11·14·16편과 동일) import type { Transaction, ExpenseCategory, Category } from '../models' import { groupBy } from '../lib/arrayUtils.ts' import type { ElementOf, UnwrapPromise } from '../lib/types/utility.ts' export class StatsCalculator implements StatsSource { // #cache, categoryTotals, monthlySummary, latestTransaction, clearCache는 기존과 동일 categoryGroups(transactions: Transaction[]): Record<Category, Transaction[]> { return groupBy(transactions, (transaction) => transaction.category) } } export type CategoryGroups = ReturnType<StatsCalculator['categoryGroups']> export type GroupedTransaction = ElementOf<CategoryGroups[Category]> export async function fetchCategoryGroups(transactions: Transaction[]): Promise<CategoryGroups> { return new StatsCalculator().categoryGroups(transactions) } export type FetchedCategoryGroups = UnwrapPromise<ReturnType<typeof fetchCategoryGroups>>

CategoryGroupscategoryGroups 메서드가 실제로 반환하는 타입(Record<Category, Transaction[]>)을 ReturnType으로 그대로 가져온 것입니다. GroupedTransactionElementOf로 그 결과값(Transaction[])의 원소 타입을 다시 뽑아내, 결국 Transaction이 됩니다. fetchCategoryGroups는 15편에서 만들 실제 API 클라이언트를 미리 흉내 낸 함수로, 계산 결과를 Promise로 감싸 돌려줍니다. FetchedCategoryGroupsUnwrapPromise로 그 Promise를 벗겨내, CategoryGroups와 똑같은 타입이 됩니다. 편집기에서 FetchedCategoryGroups 위에 마우스를 올리면 Promise가 아니라 Record<Category, Transaction[]>로 풀려있는 것을 확인할 수 있습니다.

4. 배럴에 반영

// expense-tracker/src/services/index.ts export { Storage } from './storage.ts' export type { DataStore } from './storage.ts' export { StatsCalculator, logAndCompute, fetchCategoryGroups } from './stats.ts' export type { CategoryTotals, MonthlySummary, CategoryGroups } from './stats.ts' export { BudgetEvaluator, LimitExceededRule, ApproachingLimitRule } from './budgetRules.ts' export type { BudgetRule } from './budgetRules.ts'

5. statsPanel.ts에 카테고리별 거래 건수 표시

// expense-tracker/src/ui/statsPanel.ts (발췌 — 이 편에서 추가된 부분만, 나머지는 16편과 동일) import type { Transaction, ExpenseCategory, Category } from '../models' import { StatsCalculator, BudgetEvaluator, LimitExceededRule, ApproachingLimitRule } from '../services' import { getRequiredElement } from '../lib/dom.ts' // root.innerHTML 템플릿에 다음 section을 추가합니다 // <section><h2>카테고리별 거래 건수</h2><ul id="category-counts"></ul></section> export function mountStatsPanel(root: HTMLElement) { const countsEl = getRequiredElement<HTMLUListElement>(root, '#category-counts') // totalsEl, summaryEl, warningsEl, latestEl 선언은 16편과 동일 return { update(transactions: Transaction[]): void { // 카테고리 합계, 이번 달 요약, 예산 경고, 최근 거래 렌더링은 15·16편과 동일 const groups = statsCalculator.categoryGroups(transactions) countsEl.innerHTML = (Object.keys(groups) as Category[]) .map((category) => `<li>${category}: ${groups[category].length}건</li>`) .join('') }, } }

Object.keys(groups)는 실제로 거래가 있었던 카테고리 이름만 문자열 배열로 돌려줍니다. groups[category]Category가 리터럴 유니온으로 고정된 키라서 noUncheckedIndexedAccess의 영향을 받지 않고 곧바로 Transaction[]으로 취급됩니다.

6. 실행

npx tsc --noEmit npm run dev

확인

  • npx tsc --noEmit이 오류 없이 통과한다.
  • 거래를 몇 건 추가하면 “카테고리별 거래 건수” 목록에 실제로 등장한 카테고리만 건수와 함께 나타난다.
  • 편집기에서 GroupedTransaction, FetchedCategoryGroups 타입 위에 마우스를 올리면 둘 다 Transaction 계열 타입으로 풀려있는 것이 보인다.

직접 해보기

  1. groupBy를 이용해 거래를 월(transaction.date.slice(0, 7)) 기준으로 묶는 groupByMonth 함수를 stats.ts에 추가하고, ElementOf로 그 결과의 원소 타입이 여전히 Transaction인지 확인해보세요.
  2. ElementOf<Transaction>(배열이 아닌 타입을 넣은 경우)을 어딘가에 선언해보고, 결과 타입이 never가 되는 것을 편집기에서 확인해보세요.

정답 보기

groupByMonth(transactions: Transaction[]): Record<string, Transaction[]> { return groupBy(transactions, (transaction) => transaction.date.slice(0, 7)) }

ElementOf<ReturnType<StatsCalculator['groupByMonth']>[string]>Transaction이 됩니다. 그룹 기준이 카테고리든 월이든 배열의 원소 타입을 뽑아내는 방식은 동일합니다.

ElementOf<Transaction>Transaction이 배열이 아니므로 조건이 거짓이 되어 never가 됩니다.

자주 하는 실수

증상원인고치는 법
infer로 뽑은 타입 변수를 조건 왼쪽에서 참조infer는 extends 절 오른쪽에서만 선언·사용 가능T extends Promise<infer U> ? U : T처럼 참 분기에서만 사용한다
ElementOf에 유니온을 넘겼는데 예상과 다른 결과분산 조건부 타입 특성을 고려하지 않음유니온 각 멤버가 따로 계산된 뒤 합쳐진다는 점을 감안해 예상 결과를 다시 계산한다
groups[category]가 undefined일까 봐 옵셔널 체이닝을 씀Record의 키가 리터럴 유니온으로 고정된 경우와 진짜 인덱스 시그니처를 헷갈림Category처럼 키가 고정된 Record는 noUncheckedIndexedAccess의 영향을 받지 않는다

확인 문제

문제 14지선다
T extends Promise<infer U> ? U : T에서 infer U가 하는 역할은
문제 24지선다
분산 조건부 타입이 일어나는 조건으로 가장 알맞은 것은
문제 34지선다
이 편에서 fetchCategoryGroups를 Promise로 감싼 함수로 만든 이유는
문제 44지선다
Category처럼 리터럴 유니온으로 키가 고정된 Record 타입에서 groups[category]에 노출되는 타입은

참고 자료

Last updated on