Skip to Content
WebTypeScriptTypeScript 실무09. 판별 유니온과 as const로 Props 설계하기

이번 편의 결과물: 책 상태를 보여주는 StatusBadge 컴포넌트가 판별 유니온 props로 재설계되어, 상태와 맞지 않는 값을 넘기면 컴파일 에러가 납니다. · 다루는 개념: as const로 상수 배열에서 유니온 타입 도출, 판별 유니온 기반 컴포넌트 props 설계, 불가능한 상태를 타입으로 제거하기

이 편에서 만드는 파일

bookshelf-ts/src/ ├── types/ │ └── book.ts ~ 수정 (손으로 쓴 유니온 → as const 기반 BookStatus) └── components/ ├── StatusBadge.tsx + 신규 └── BookCard.tsx ~ 수정 (STATUS_KEY 텍스트 표시 → StatusBadge로 교체)

개념 정리

04편에서 types/book.tsBookStatus를 정의할 때 값을 나열한 유니온 타입을 손으로 썼습니다.

export type BookStatus = 'wish' | 'reading' | 'done'

BookCard.tsx(04편)에는 이 값을 화면 문구 키로 바꾸는 STATUS_KEY: Record<BookStatus, string> 객체가 있었습니다. 상태가 하나 더 늘어나면 BookStatus 유니온과 STATUS_KEY 양쪽을 따로 고쳐야 합니다. 이 편에서는 값 목록 하나로 타입까지 도출하도록 정리하고, 상태별로 함께 있어야 할 정보(며칠째 읽는 중인지, 별점)가 다른 문제도 판별 유니온으로 해결합니다.

as const로 유니온 도출하기

typescript_2에서 as const를 배웠습니다. 배열 리터럴 뒤에 붙이면 각 원소가 넓은 타입(string)이 아니라 그 값 자체의 리터럴 타입으로 좁혀지고, 배열도 읽기 전용 튜플이 됩니다.

const rawStatuses = ['wish', 'reading', 'done'] // string[] — 원소가 무엇이든 될 수 있는 넓은 타입 const constStatuses = ['wish', 'reading', 'done'] as const // readonly ['wish', 'reading', 'done'] — 원소 하나하나가 리터럴 타입

typeof constStatuses[number]로 튜플의 원소 타입만 뽑으면 'wish' | 'reading' | 'done' 유니온이 됩니다. 값과 타입을 같은 배열 하나에서 함께 관리하는 방법입니다.

판별 유니온으로 불가능한 상태 없애기

지금까지 컴포넌트 props를 설계할 때 관련 있는 필드를 전부 선택적(?)으로 늘어놓는 방식을 썼을 수 있습니다.

방식문제
{ status: BookStatus; rating?: number; startedAt?: string }status'wish'인데 rating을 같이 넘기는 조합도 타입으로는 허용됨
{ status: 'wish' } | { status: 'reading'; startedAt: string } | { status: 'done'; rating: number }status값에 따라 함께 있어야 할 필드가 정확히 정해짐

두 번째 방식이 판별 유니온(discriminated union)입니다. status 필드가 판별자(discriminant) 역할을 해서, 그 값에 따라 나머지 필드의 존재 여부와 타입이 자동으로 좁혀집니다. 컴포넌트 props에 적용하면, 상태에 맞지 않는 조합 자체가 만들어지지 않습니다.

실습

1. types/book.ts에 as const 기반 BookStatus 적용

// src/types/book.ts (BookStatus 선언만 교체 — Book 인터페이스는 04편 코드 그대로) export const BOOK_STATUSES = ['wish', 'reading', 'done'] as const export type BookStatus = (typeof BOOK_STATUSES)[number] export interface Book { id: number title: string author: string status: BookStatus rating: number pages: number coverColor: string coverImage?: string startedAt: string finishedAt: string | null memo: string }

BOOK_STATUSES는 값과 타입을 동시에 제공합니다. 값으로는 상태 선택 UI에서 목록을 순회할 때 쓰고, 타입으로는 BookStatus'wish' | 'reading' | 'done'로 정확히 좁혀지도록 씁니다. 상태가 하나 늘어나면 이 배열 한 줄만 고치면 됩니다. Book 인터페이스 자체는 04편 그대로 두었습니다.

2. StatusBadge 컴포넌트 작성

// src/components/StatusBadge.tsx import { useTranslation } from 'react-i18next' import type { Book, BookStatus } from '../types/book.ts' const STATUS_META = { wish: { labelKey: 'statusWish', color: '#64748b' }, reading: { labelKey: 'statusReading', color: '#2563eb' }, done: { labelKey: 'statusDone', color: '#16a34a' }, } satisfies Record<BookStatus, { labelKey: string; color: string }> type StatusBadgeProps = | { status: 'wish' } | { status: 'reading'; startedAt: string } | { status: 'done'; rating: number } function formatReadingDays(startedAt: string): string { const startedTime = new Date(startedAt).getTime() const days = Math.floor((Date.now() - startedTime) / (1000 * 60 * 60 * 24)) return `${Math.max(days, 0)}일째` } function StatusBadge(props: StatusBadgeProps) { const { t } = useTranslation() const meta = STATUS_META[props.status] return ( <span className="status-badge" style={{ backgroundColor: meta.color }}> {t(`bookCard.${meta.labelKey}`)} {props.status === 'reading' && ( <span className="status-badge-detail"> · {formatReadingDays(props.startedAt)}</span> )} {props.status === 'done' && <span className="status-badge-detail"> · 별점 {props.rating}</span>} </span> ) } function assertNever(value: never): never { throw new Error(`처리하지 않은 상태입니다: ${JSON.stringify(value)}`) } export function bookToStatusBadgeProps(book: Book): StatusBadgeProps { switch (book.status) { case 'wish': return { status: 'wish' } case 'reading': return { status: 'reading', startedAt: book.startedAt } case 'done': return { status: 'done', rating: book.rating } default: return assertNever(book.status) } } export default StatusBadge export type { StatusBadgeProps }

STATUS_METAsatisfies Record<BookStatus, { labelKey: string; color: string }>로 세 상태를 모두 빠짐없이 정의했는지 검사받습니다. 번역 키(labelKey)는 04편의 STATUS_KEY가 쓰던 bookCard.statusWish 같은 키를 그대로 이어받아, 다국어 리소스 파일은 손대지 않습니다. StatusBadge 안에서는 props.status === 'reading'으로 좁힌 블록에서만 props.startedAt을 읽을 수 있고, 'done' 블록에서만 props.rating을 읽을 수 있습니다. bookToStatusBadgePropsswitch는 세 분기를 모두 다뤄야 컴파일되고, 빠뜨린 분기가 있으면 defaultassertNever(book.status)에서 오류로 드러납니다.

3. BookCard에서 StatusBadge 사용

// src/components/BookCard.tsx (04편 코드에서 상태 표시 부분만 교체 — BookCover·formatDate는 그대로 유지) import { useTranslation } from 'react-i18next' import type { Book } from '../types/book.ts' import StatusBadge, { bookToStatusBadgeProps } from './StatusBadge.tsx' interface BookCardProps { book: Book } function formatDate(dateString: string | null, locale: string): string { if (!dateString) return '-' return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(dateString)) } function BookCover({ book }: BookCardProps) { if (book.coverImage) { return <img className="book-cover" src={book.coverImage} alt={`${book.title} 표지`} /> } return <div className="book-cover-placeholder" style={{ backgroundColor: book.coverColor }} /> } function BookCard({ book }: BookCardProps) { const { t, i18n } = useTranslation() return ( <li className="book-card"> <BookCover book={book} /> <h3>{book.title}</h3> <p>{book.author}</p> <StatusBadge {...bookToStatusBadgeProps(book)} /> <p>{t('bookCard.pages', { count: book.pages })}</p> <p>{t('bookCard.startedAt', { date: formatDate(book.startedAt, i18n.language) })}</p> </li> ) } export default BookCard

STATUS_KEY 객체와 그 객체를 조회하던 <p> 한 줄이 사라지고, BookCardBook 전체를 bookToStatusBadgeProps에 넘기기만 합니다. 상태별로 어떤 필드를 뽑아 넘길지는 StatusBadge.tsx 한 곳에만 몰려 있습니다.

4. 실행

npm run dev

5. 확인

  • 책 목록에서 상태읽고 싶음인 카드는 색상 뱃지만 보이고, 읽는 중인 카드는 며칠째인지, 다 읽음인 카드는 별점이 함께 보입니다.
  • VS Code에서 StatusBadge 사용 부분에 { status: 'wish', rating: 5 }처럼 억지로 넘겨보면 빨간 밑줄과 함께 타입 오류가 뜹니다.
  • bookToStatusBadgePropsswitch에서 case 'reading':을 지워보면 defaultassertNever(book.status) 줄에서 오류가 나는 것을 확인합니다. 지운 케이스를 되돌려 오류가 사라지는지도 확인합니다.

직접 해보기

  1. BOOK_STATUSES'paused'(읽다 중단)를 추가해 보세요. 배열에 문자열 하나만 추가하면 BookStatus 유니온에도 자동으로 반영됩니다. 이어서 STATUS_METAStatusBadgeProps, bookToStatusBadgeProps에서 어떤 부분이 컴파일 오류로 드러나는지 확인해 보세요.
  2. StatusBadgeProps'done' 분기에 finishedAt: string 필드를 추가하고, 뱃지에 완독 날짜도 함께 표시해 보세요.

정답 보기

// src/types/book.ts export const BOOK_STATUSES = ['wish', 'reading', 'paused', 'done'] as const

이 한 줄만 고치면 STATUS_META(satisfies Record<BookStatus, ...>)와 bookToStatusBadgePropsswitch(assertNever 분기) 두 곳에서 각각 'paused' 키·분기가 빠졌다는 오류가 납니다. 배열 하나를 고쳤을 뿐인데 이 값을 다루는 모든 곳이 컴파일 타임에 드러나는 것이 판별 유니온과 as const를 함께 쓰는 이유입니다.

자주 하는 실수

증상원인고치는 법
as const를 붙였는데도 원소가 string으로 추론됨배열이 아니라 개별 변수 선언에 as const를 빠뜨림배열 리터럴 전체(['wish', 'reading', 'done'] as const)에 붙였는지 확인
StatusBadgestatus만 넘겼는데 rating이 없다는 오류가 남status: 'done'인데 rating을 빠뜨림판별 유니온의 해당 분기가 요구하는 필드를 모두 넘긴다
props.startedAt에 접근하는 곳에서 프로퍼티가 없다는 오류가 남if (props.status === 'reading') 같은 좁히기 없이 바로 접근판별자로 먼저 좁힌 블록 안에서만 해당 필드에 접근한다
assertNever 호출에서 인자 타입이 never가 아니라는 오류가 남switch에서 처리하지 않은 상태 값이 남아 있음빠뜨린 case를 추가하거나 BookStatus에 실제로 없는 값인지 확인

확인 문제

문제 14지선다
다음 두 선언의 차이를 설명한 것으로 옳은 것은
const a = ['wish', 'reading', 'done']
const b = ['wish', 'reading', 'done'] as const
문제 24지선다
StatusBadgeProps 판별 유니온에서 status 필드가 하는 역할은
문제 34지선다
status를 reading, rating을 5로 함께 넘겨 StatusBadge를 호출하면 어떤 일이 일어나는가
문제 44지선다
satisfies Record 형태로 STATUS_META를 정의하는 이유로 가장 알맞은 것은
문제 54지선다
bookToStatusBadgeProps의 switch에서 case 하나를 실수로 지웠을 때 default의 assertNever(book.status) 호출이 오류를 내는 이유는

참고 자료

Last updated on