Skip to Content
WebTypeScriptTypeScript 중급05. 매핑된 타입 심화: 키 리매핑

이번 편의 결과물: lib/types/utility.ts에 키 리매핑으로 만든 FormErrors 타입을 추가하고, 거래 입력 폼에 적용해 필드별 에러 메시지를 화면에 표시합니다. · 다루는 개념: as 절을 이용한 키 리매핑, Exclude와 결합한 키 거르기, 접두사·접미사 붙은 키 생성

03편에서 lib/types/utility.ts에 조건부 타입 UnwrapPromise, ElementOf를 작성했습니다. 이 편에서는 같은 파일에 매핑된 타입 하나를 더 추가합니다. 지금까지 거래 입력 폼은 값이 잘못돼도 alert 한 번 띄우고 끝이었습니다. 필드마다 다른 에러 메시지를 붙이려면 “어떤 필드에 어떤 에러가 있는가”를 나타내는 타입이 먼저 필요합니다.

이 편에서 만드는 파일

expense-tracker/src/ ├── lib/ │ └── types/ │ └── utility.ts ~ (FormErrors 추가) └── ui/ └── transactionForm.ts ~ (FormErrors 적용, 필드별 에러 렌더링)

개념 정리

as 절로 키 이름 바꾸기

매핑된 타입은 원래 keyof T가 만든 키 이름을 그대로 씁니다. as 절을 뒤에 붙이면 그 키 이름을 다른 문자열로 바꿔서 새 타입을 만들 수 있습니다.

type Prefixed<T> = { [K in keyof T as `input${Capitalize<string & K>}`]: T[K] }

{ date: string; amount: number } 모양을 이 타입에 넣으면 inputDate, inputAmount라는 새 키를 가진 타입이 됩니다. string & K는 “K이면서 동시에 string인 타입”이라는 뜻으로, 템플릿 리터럴 자리에는 문자열만 올 수 있다는 제약을 만족시키기 위한 관용구입니다.

Exclude와 결합해 키 거르기

as 절의 결과로 never를 주면 그 키는 결과 타입에서 사라집니다. 이 성질과 Exclude를 결합하면 특정 키만 제외한 매핑된 타입을 만들 수 있습니다.

type WithoutId<T> = { [K in Exclude<keyof T, 'id'>]: T[K] }

Exclude<keyof T, 'id'>가 먼저 'id'를 뺀 키 목록을 계산하고, 매핑된 타입은 그 목록만 순회합니다.

FormErrors 설계

거래 입력 폼에서 실제로 값을 검증할 칸은 date, amount, memo 세 개뿐입니다. type, categoryselect 요소라 사용자가 목록에 없는 값을 고를 수 없어 에러 메시지가 필요 없습니다. 이 두 키를 Exclude로 걸러내고, 남은 키 이름 끝에 Error를 붙인 선택적 문자열 프로퍼티로 리매핑하면 FormErrors 타입이 됩니다. 이렇게 만든 에러 객체는 존재하지 않는 필드 이름으로 실수를 저지르면 그 자리에서 컴파일 에러가 나서 알려줍니다.

실습

1. utility.ts에 FormErrors 추가

// 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 /** * date, amount, memo 키만 남기고 이름 끝에 Error를 붙인 * 선택적 문자열 프로퍼티로 바꿉니다. * type, category는 select 입력이라 에러 메시지가 필요 없어 제외합니다. */ export type FormErrors<T> = { [K in Exclude<keyof T, 'type' | 'category'> as `${string & K}Error`]?: string }

2. transactionForm.ts에 검증 로직과 에러 표시 추가

// expense-tracker/src/ui/transactionForm.ts import type { Transaction, ExpenseCategory, IncomeCategory } from '../models' import { getRequiredElement } from '../lib/dom.ts' import type { FormErrors } from '../lib/types/utility.ts' export type NewTransactionDraft = Omit<Transaction, 'id'> export type EditableFields = Pick<Transaction, 'date' | 'amount' | 'memo'> export interface TransactionFormHandlers { onCreate: (draft: NewTransactionDraft) => void onUpdate: (id: string, changes: Partial<EditableFields>) => void } const EXPENSE_CATEGORIES: ExpenseCategory[] = ['food', 'transport', 'housing', 'shopping', 'etc-expense'] const INCOME_CATEGORIES: IncomeCategory[] = ['salary', 'bonus', 'interest', 'etc-income'] function categoryOptionsHtml(type: Transaction['type']): string { const categories = type === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES return categories.map((category) => `<option value="${category}">${category}</option>`).join('') } function validateDraft(draft: NewTransactionDraft): FormErrors<NewTransactionDraft> { const errors: FormErrors<NewTransactionDraft> = {} if (draft.date === '') { errors.dateError = '날짜를 입력하세요.' } if (!Number.isFinite(draft.amount) || draft.amount <= 0) { errors.amountError = '금액은 0보다 큰 숫자여야 합니다.' } if (draft.memo.length > 50) { errors.memoError = '메모는 50자 이내로 입력하세요.' } return errors } export function mountTransactionForm(root: HTMLElement, handlers: TransactionFormHandlers) { root.innerHTML = ` <form id="transaction-form"> <select name="type" id="type-select"> <option value="expense">지출</option> <option value="income">수입</option> </select> <select name="category" id="category-select"></select> <input name="date" type="date" required /> <span id="date-error" class="field-error"></span> <input name="amount" type="number" required /> <span id="amount-error" class="field-error"></span> <input name="memo" type="text" placeholder="메모" /> <span id="memo-error" class="field-error"></span> <button type="submit">추가</button> </form> <form id="edit-form" hidden> <p>거래 수정</p> <input name="date" type="date" required /> <input name="amount" type="number" required /> <input name="memo" type="text" placeholder="메모" /> <button type="submit">저장</button> <button type="button" id="edit-cancel">취소</button> </form> ` const form = getRequiredElement<HTMLFormElement>(root, '#transaction-form') const typeSelect = getRequiredElement<HTMLSelectElement>(root, '#type-select') const categorySelect = getRequiredElement<HTMLSelectElement>(root, '#category-select') const dateErrorEl = getRequiredElement<HTMLSpanElement>(root, '#date-error') const amountErrorEl = getRequiredElement<HTMLSpanElement>(root, '#amount-error') const memoErrorEl = getRequiredElement<HTMLSpanElement>(root, '#memo-error') const editForm = getRequiredElement<HTMLFormElement>(root, '#edit-form') const editCancelButton = getRequiredElement<HTMLButtonElement>(root, '#edit-cancel') let editingId: string | null = null function refreshCategoryOptions(): void { categorySelect.innerHTML = categoryOptionsHtml(typeSelect.value as Transaction['type']) } function renderErrors(errors: FormErrors<NewTransactionDraft>): void { dateErrorEl.textContent = errors.dateError ?? '' amountErrorEl.textContent = errors.amountError ?? '' memoErrorEl.textContent = errors.memoError ?? '' } function handleCreateSubmit(event: SubmitEvent): void { event.preventDefault() const formData = new FormData(form) const type = formData.get('type') as Transaction['type'] const date = String(formData.get('date')) const amount = Number(formData.get('amount')) const memo = String(formData.get('memo')) const draft: NewTransactionDraft = type === 'expense' ? { type: 'expense', date, amount, memo, category: formData.get('category') as ExpenseCategory } : { type: 'income', date, amount, memo, category: formData.get('category') as IncomeCategory } const errors = validateDraft(draft) if (Object.keys(errors).length > 0) { renderErrors(errors) return } renderErrors({}) handlers.onCreate(draft) form.reset() refreshCategoryOptions() } function openEdit(transaction: Transaction): void { editingId = transaction.id const dateInput = editForm.elements.namedItem('date') as HTMLInputElement const amountInput = editForm.elements.namedItem('amount') as HTMLInputElement const memoInput = editForm.elements.namedItem('memo') as HTMLInputElement dateInput.value = transaction.date amountInput.value = String(transaction.amount) memoInput.value = transaction.memo editForm.hidden = false } function closeEdit(): void { editingId = null editForm.hidden = true editForm.reset() } function handleEditSubmit(event: SubmitEvent): void { event.preventDefault() if (editingId === null) { return } const formData = new FormData(editForm) handlers.onUpdate(editingId, { date: String(formData.get('date')), amount: Number(formData.get('amount')), memo: String(formData.get('memo')), }) closeEdit() } form.addEventListener('submit', handleCreateSubmit) typeSelect.addEventListener('change', refreshCategoryOptions) editForm.addEventListener('submit', handleEditSubmit) editCancelButton.addEventListener('click', closeEdit) refreshCategoryOptions() return { openEdit } }

validateDraft가 반환하는 타입은 FormErrors이므로, errors.dateError처럼 실제로 존재하는 필드만 안전하게 읽고 쓸 수 있습니다. errors.categoryError처럼 없는 이름을 쓰면 그 줄에서 바로 컴파일 에러가 납니다.

3. 실행

npm run dev

4. 확인

  • 날짜를 비우거나 금액에 0 이하 값을 넣고 “추가”를 누르면 해당 입력 아래에 빨간 글자 없이도(스타일은 다음 편에서) 에러 문구가 나타난다.
  • 값을 올바르게 고치고 다시 제출하면 에러 문구가 지워지고 거래가 목록에 추가된다.
  • npx tsc --noEmit이 오류 없이 끝난다.

직접 해보기

  1. validateDraft 안에 errors.categoryError = '아무 값'처럼 존재하지 않는 키를 대입하는 줄을 추가해보고, 편집기가 어떤 오류를 보여주는지 확인한 뒤 지웁니다.
  2. FormErrors의 제외 목록에서 'category'를 빼고(Exclude<keyof T, 'type'>만 남기고) categoryError가 실제로 타입에 생기는지 확인한 뒤 원래대로 되돌립니다.

정답 보기

1번은 Object literal may only specify known properties류의 오류가 납니다. FormErrors가 만든 타입에는 categoryError라는 키 자체가 없기 때문입니다.

2번처럼 제외 목록을 줄이면 FormErrors 결과 타입에 categoryError가 선택적 문자열 프로퍼티로 추가됩니다. 다만 validateDraft가 이 필드를 채우는 코드는 없으므로, 타입에는 있지만 실제로 쓰이지 않는 필드가 됩니다.

자주 하는 실수

증상원인고치는 법
템플릿 리터럴 키 타입 선언에서 오류`${K}Error`처럼 K를 그대로 씀`${string & K}Error`처럼 string과 교차시켜 문자열임을 명시한다
존재하지 않는 에러 필드에 값을 대입해 컴파일 에러FormErrors가 만든 타입에 없는 키 이름을 사용실제로 검증하는 필드만 골라 이름을 맞춘다
에러 메시지 칸에 항상 undefined라는 글자가 보임옵셔널 필드를 그대로 textContent에 대입errors.dateError ?? ''처럼 값이 없을 때 빈 문자열로 바꾼다

확인 문제

문제 14지선다
매핑된 타입에서 as 절이 하는 역할은
문제 24지선다
매핑된 타입의 as 절 결과로 never를 주면 생기는 일은
문제 34지선다
FormErrors 타입에서 type, category 키를 제외한 이유는
문제 44지선다
존재하지 않는 에러 필드 이름에 값을 대입하면 FormErrors 타입에서 어떤 일이 생기는가

참고 자료

Last updated on