iBetter Books
수정

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를 붙인다.
  • 여러 필드가 함께 사용되는가. 인터섹션이나 별도 인터페이스로 분리한다.
  • 반환값 타입이 실제 반환하는 값보다 넓지 않은가. 타입 가드로 좁혀 반환한다.

타입 설계는 비즈니스 로직을 이해하는 일입니다. 코드가 아니라 도메인을 먼저 이해하면 자연스럽게 좋은 타입이 나옵니다.