Ch 03. 좋은 타입 vs 나쁜 타입
코드는 동작하는데 타입은 엉망인 경우가 있습니다. 컴파일이 통과하더라도 타입이 너무 넓으면 버그를 잡지 못하고, 너무 좁으면 정상적인 사용을 막습니다. 이 챕터에서는 Before/After 비교를 통해 타입 설계의 원칙을 체득합니다.
원칙 1. 가능한 한 좁은 타입을 사용하기
넓은 타입은 더 많은 값을 허용하지만, 그만큼 TypeScript가 잡아주는 실수도 줄어듭니다.
// 파일: src/type-design/narrow-vs-wide.ts// 나쁜 타입 — 너무 넓음interface UserBad { role: string; // "admin", "guest", "superuser" 등 뭐든 들어옴 status: number; // 1, 2, 3이 무엇인지 알 수 없음 config: object; // 어떤 프로퍼티가 있는지 알 수 없음}// 좋은 타입 — 충분히 좁음type UserRole = "admin" | "editor" | "viewer";type UserStatus = "active" | "inactive" | "suspended";interface UserConfig { theme: "light" | "dark"; language: "ko" | "en" | "ja"; notifications: boolean;}interface User { role: UserRole; status: UserStatus; config: UserConfig;}// 이제 잘못된 값을 넣으면 컴파일 에러const user: User = { role: "superuser", // 에러: "superuser"는 UserRole에 없음 status: 1, // 에러: 숫자는 UserStatus가 아님 config: {}, // 에러: 필수 프로퍼티 누락};
원칙 2. 불가능한 상태를 타입으로 표현하지 않기
여러 필드가 함께 바뀌어야 하는 상태를 각각의 선택적 필드로 표현하면 "있어서는 안 되는 조합"이 생깁니다.
// 파일: src/type-design/impossible-state.ts// 나쁜 타입 — 불가능한 상태가 표현 가능interface AsyncStateBad { isLoading: boolean; data?: User[]; error?: string; // isLoading=true이면서 data가 있는 것은 말이 안 됨 // isLoading=false이면서 data도 error도 없는 것도 말이 안 됨}// 좋은 타입 — 판별 유니온으로 불가능한 상태 제거type AsyncState<T> = | { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: string };type UserListState = AsyncState<User[]>;function renderState(state: UserListState) { switch (state.status) { case "idle": return "아직 로드하지 않았습니다."; case "loading": return "불러오는 중..."; case "success": return `${state.data.length}명의 사용자`; case "error": return `오류 발생: ${state.error}`; }}
원칙 3. 함수 매개변수는 넓게, 반환값은 좁게
함수를 사용하는 입장에서는 다양한 타입을 넘길 수 있어야 유연하고, 반환값은 구체적이어야 이후 코드에서 활용하기 쉽습니다.
// 파일: src/type-design/in-out-types.ts// 나쁜 패턴 — 매개변수 너무 좁음function sumBad(arr: number[]): number { return arr.reduce((a, b) => a + b, 0);}// ReadonlyArray, Tuple 등을 넘길 수 없음// 좋은 패턴 — 매개변수는 넓게 (읽기 전용 배열 허용)function sum(arr: readonly number[]): number { return arr.reduce((a, b) => a + b, 0);}const nums = [1, 2, 3] as const;sum(nums); // 정상 동작// 나쁜 패턴 — 반환값 너무 넓음function getAdminsBad(users: User[]): User[] { return users.filter((u) => u.role === "admin"); // 반환값이 admin만 있다는 정보가 손실됨}// 좋은 패턴 — 반환값은 좁게type AdminUser = User & { role: "admin" };function getAdmins(users: User[]): AdminUser[] { return users.filter((u): u is AdminUser => u.role === "admin");}const admins = getAdmins(allUsers);// admins의 각 요소는 role이 반드시 "admin"임이 보장됨
원칙 4. 인터섹션 타입으로 조합하기 (Before/After)
여러 역할을 가진 타입을 만들 때 모든 필드를 하나의 인터페이스에 넣지 않고 조합합니다.
// 파일: src/type-design/intersection.ts// Before — 모든 필드가 한 타입에 뭉쳐있음interface EntityBefore { id: string; createdAt: Date; updatedAt: Date; name: string; email: string; role: UserRole; price?: number; category?: string;}// After — 역할별로 분리, 인터섹션으로 조합interface Identifiable { id: string;}interface Timestamped { createdAt: Date; updatedAt: Date;}interface UserProfile { name: string; email: string; role: UserRole;}interface ProductInfo { price: number; category: string;}// 조합type User = Identifiable & Timestamped & UserProfile;type Product = Identifiable & Timestamped & ProductInfo;// 공통 로직은 공통 타입으로function getAge(entity: Timestamped): number { return Date.now() - entity.createdAt.getTime();}getAge(user); // 정상getAge(product); // 정상
원칙 5. readonly로 불변성 표현하기
변경되어서는 안 되는 데이터는 타입으로 불변성을 표현합니다.
// 파일: src/type-design/readonly.ts// Before — 실수로 수정 가능interface ConfigBefore { apiUrl: string; timeout: number; features: string[];}// After — 수정 불가interface Config { readonly apiUrl: string; readonly timeout: number; readonly features: readonly string[];}const config: Config = { apiUrl: "https://api.example.com", timeout: 3000, features: ["auth", "analytics"],};config.apiUrl = "https://other.com"; // 에러: 읽기 전용 프로퍼티config.features.push("debug"); // 에러: 읽기 전용 배열// Readonly 유틸리티 타입interface Settings { theme: "light" | "dark"; language: string;}type FrozenSettings = Readonly<Settings>;const settings: FrozenSettings = { theme: "light", language: "ko" };settings.theme = "dark"; // 에러
원칙 6. 옵셔널 대신 유니온 타입 선호하기
선택적 프로퍼티(?)는 undefined를 허용합니다. 필드가 "없는 상태"와 "있지만 undefined인 상태"가 구분될 때는 명시적 유니온이 더 좋습니다.
// 파일: src/type-design/optional-vs-union.ts// Before — 의미가 모호한 옵셔널interface SearchResultBefore { query: string; results?: Product[]; // 검색 전? 결과 없음? 구분 불가 totalCount?: number; error?: string;}// After — 상태가 명확한 유니온type SearchResult = | { status: "idle" } | { status: "loading"; query: string } | { status: "success"; query: string; results: Product[]; totalCount: number } | { status: "empty"; query: string } | { status: "error"; query: string; error: string };function handleSearch(result: SearchResult) { if (result.status === "success") { // result.results, result.totalCount 사용 가능 — 보장됨 console.log(`${result.totalCount}개 결과 중 ${result.results.length}개 표시`); }}
원칙 7. 타입 리팩터링 — 실전 Before/After
실제 코드에서 타입을 개선하는 전체 흐름입니다.
// 파일: src/type-design/refactor-example.ts// Before — 실무에서 자주 보이는 "일단 돌아는 가는" 타입interface InvoiceBefore { id: any; customerId: any; items: any[]; status: string; total: number; paidAt: string | null; metadata: object;}// After — 타입이 비즈니스 규칙을 표현type InvoiceId = string & { readonly __brand: "InvoiceId" };type CustomerId = string & { readonly __brand: "CustomerId" };interface LineItem { productId: string; name: string; quantity: number; unitPrice: number;}type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";interface InvoiceMeta { notes?: string; tags: string[];}type Invoice = | { status: "draft" | "sent" | "overdue"; id: InvoiceId; customerId: CustomerId; items: LineItem[]; total: number; paidAt: null; metadata: InvoiceMeta; } | { status: "paid"; id: InvoiceId; customerId: CustomerId; items: LineItem[]; total: number; paidAt: Date; // paid 상태에서는 반드시 존재 metadata: InvoiceMeta; };function markAsPaid(invoice: Invoice): Invoice { if (invoice.status === "paid") { throw new Error("이미 결제된 청구서입니다."); } return { ...invoice, status: "paid", paidAt: new Date() };}
브랜드 타입(InvoiceId, CustomerId)은 같은 string 타입이지만 섞어 쓰는 실수를 컴파일 시점에 잡습니다. markAsPaid(invoice.customerId) 같은 실수를 TypeScript가 거부합니다.
타입 설계 체크리스트
좋은 타입을 작성하기 전에 스스로 점검할 질문들입니다.
- 이 타입으로 표현되는 "불가능한 상태"가 있는가. 있다면 판별 유니온을 쓴다.
- string, number 같은 원시 타입으로 표현하고 있지만 실제로는 특정 값만 허용하는가. 리터럴 유니온으로 좁힌다.
- 변경되어서는 안 되는 데이터인가. readonly를 붙인다.
- 여러 필드가 함께 사용되는가. 인터섹션이나 별도 인터페이스로 분리한다.
- 반환값 타입이 실제 반환하는 값보다 넓지 않은가. 타입 가드로 좁혀 반환한다.
타입 설계는 비즈니스 로직을 이해하는 일입니다. 코드가 아니라 도메인을 먼저 이해하면 자연스럽게 좋은 타입이 나옵니다.