diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5725e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +- 인코딩은 항상 UTF-8 +- 주석/답변은 한국어 + +## Context + +@.claude/core/FLAGS.md + +## 파일 작성 규칙 + +### 한글/이모지 포함 파일 생성 시 + +- Write/Edit 도구 대신 Bash heredoc 사용: + +```bash +cat << 'EOF' > filename.md +한글과 이모지가 포함된 내용 +EOF +``` + +- 'EOF'를 따옴표로 감싸서 변수 확장 방지 + +## 문서화 규칙 + +- 공통 코드(ErrorResponse DTO, HTTPClient Config, AuthStatus 등)은 이후 참조할 수 있도록 코드 위치, 사용법, 동작, 반환값, 파라미터 등을 README.md에 명시 +- CustomStatus 등 에러 코드는 중복되지 않게 README.md에 추가될 때마다 기록 + +## 코딩 원칙 + +### KISS (Keep It Simple, Stupid) + +단순하고 명확한 코드를 작성하라. 불필요한 복잡성은 기술 부채다. + +준수 사항: + +- 함수는 한 가지 일만 하도록 작성 (Single Responsibility) +- 함수 길이는 가급적 50줄 이하로 유지 +- 중첩 깊이는 3단계를 넘지 않도록 +- 복잡한 로직은 명확한 이름의 작은 함수로 분리 +- 자기 설명적 코드 작성 (변수/함수명으로 의도를 표현) +- 과도한 디자인 패턴이나 추상화 지양 + +금지 사항: + +- 한 번에 이해하기 어려운 복잡한 원라이너 +- 불필요한 레이어나 간접 참조 +- "clever" 코드보다는 "clear" 코드 + +### YAGNI (You Aren't Gonna Need It) + +현재 필요한 것만 구현하라. 미래를 위한 코드는 현재의 부담이다. + +준수 사항: + +- 명시적으로 요구된 기능만 구현 +- 실제로 3번 반복될 때 추상화 고려 (Rule of Three) +- 작게 시작하고 필요할 때 확장 +- 확실한 요구사항이 있을 때만 확장 포인트 추가 + +금지 사항: + +- "나중에 필요할 것 같아서" 추가하는 파라미터/옵션 +- 현재 사용하지 않는 인터페이스나 추상 클래스 +- "미래 대비" 설정이나 플래그 +- 사용되지 않는 제네릭 프레임워크 + diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 0000000..d5725e0 --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,65 @@ +- 인코딩은 항상 UTF-8 +- 주석/답변은 한국어 + +## Context + +@.claude/core/FLAGS.md + +## 파일 작성 규칙 + +### 한글/이모지 포함 파일 생성 시 + +- Write/Edit 도구 대신 Bash heredoc 사용: + +```bash +cat << 'EOF' > filename.md +한글과 이모지가 포함된 내용 +EOF +``` + +- 'EOF'를 따옴표로 감싸서 변수 확장 방지 + +## 문서화 규칙 + +- 공통 코드(ErrorResponse DTO, HTTPClient Config, AuthStatus 등)은 이후 참조할 수 있도록 코드 위치, 사용법, 동작, 반환값, 파라미터 등을 README.md에 명시 +- CustomStatus 등 에러 코드는 중복되지 않게 README.md에 추가될 때마다 기록 + +## 코딩 원칙 + +### KISS (Keep It Simple, Stupid) + +단순하고 명확한 코드를 작성하라. 불필요한 복잡성은 기술 부채다. + +준수 사항: + +- 함수는 한 가지 일만 하도록 작성 (Single Responsibility) +- 함수 길이는 가급적 50줄 이하로 유지 +- 중첩 깊이는 3단계를 넘지 않도록 +- 복잡한 로직은 명확한 이름의 작은 함수로 분리 +- 자기 설명적 코드 작성 (변수/함수명으로 의도를 표현) +- 과도한 디자인 패턴이나 추상화 지양 + +금지 사항: + +- 한 번에 이해하기 어려운 복잡한 원라이너 +- 불필요한 레이어나 간접 참조 +- "clever" 코드보다는 "clear" 코드 + +### YAGNI (You Aren't Gonna Need It) + +현재 필요한 것만 구현하라. 미래를 위한 코드는 현재의 부담이다. + +준수 사항: + +- 명시적으로 요구된 기능만 구현 +- 실제로 3번 반복될 때 추상화 고려 (Rule of Three) +- 작게 시작하고 필요할 때 확장 +- 확실한 요구사항이 있을 때만 확장 포인트 추가 + +금지 사항: + +- "나중에 필요할 것 같아서" 추가하는 파라미터/옵션 +- 현재 사용하지 않는 인터페이스나 추상 클래스 +- "미래 대비" 설정이나 플래그 +- 사용되지 않는 제네릭 프레임워크 + diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..d5725e0 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,65 @@ +- 인코딩은 항상 UTF-8 +- 주석/답변은 한국어 + +## Context + +@.claude/core/FLAGS.md + +## 파일 작성 규칙 + +### 한글/이모지 포함 파일 생성 시 + +- Write/Edit 도구 대신 Bash heredoc 사용: + +```bash +cat << 'EOF' > filename.md +한글과 이모지가 포함된 내용 +EOF +``` + +- 'EOF'를 따옴표로 감싸서 변수 확장 방지 + +## 문서화 규칙 + +- 공통 코드(ErrorResponse DTO, HTTPClient Config, AuthStatus 등)은 이후 참조할 수 있도록 코드 위치, 사용법, 동작, 반환값, 파라미터 등을 README.md에 명시 +- CustomStatus 등 에러 코드는 중복되지 않게 README.md에 추가될 때마다 기록 + +## 코딩 원칙 + +### KISS (Keep It Simple, Stupid) + +단순하고 명확한 코드를 작성하라. 불필요한 복잡성은 기술 부채다. + +준수 사항: + +- 함수는 한 가지 일만 하도록 작성 (Single Responsibility) +- 함수 길이는 가급적 50줄 이하로 유지 +- 중첩 깊이는 3단계를 넘지 않도록 +- 복잡한 로직은 명확한 이름의 작은 함수로 분리 +- 자기 설명적 코드 작성 (변수/함수명으로 의도를 표현) +- 과도한 디자인 패턴이나 추상화 지양 + +금지 사항: + +- 한 번에 이해하기 어려운 복잡한 원라이너 +- 불필요한 레이어나 간접 참조 +- "clever" 코드보다는 "clear" 코드 + +### YAGNI (You Aren't Gonna Need It) + +현재 필요한 것만 구현하라. 미래를 위한 코드는 현재의 부담이다. + +준수 사항: + +- 명시적으로 요구된 기능만 구현 +- 실제로 3번 반복될 때 추상화 고려 (Rule of Three) +- 작게 시작하고 필요할 때 확장 +- 확실한 요구사항이 있을 때만 확장 포인트 추가 + +금지 사항: + +- "나중에 필요할 것 같아서" 추가하는 파라미터/옵션 +- 현재 사용하지 않는 인터페이스나 추상 클래스 +- "미래 대비" 설정이나 플래그 +- 사용되지 않는 제네릭 프레임워크 + diff --git a/MISSION_GUIDE.md b/MISSION_GUIDE.md new file mode 100644 index 0000000..53fa637 --- /dev/null +++ b/MISSION_GUIDE.md @@ -0,0 +1,1145 @@ +# Effective TypeScript 미션 상세 가이드 + +이 문서는 각 주차별 미션을 CLI 프로그램으로 구현하기 위한 상세 가이드입니다. + +--- + +## 📋 전체 구조 + +### CLI 메인 메뉴 +``` +=== Effective TypeScript Study === +주차를 선택하세요: +1. WEEK 1: Inventory System +2. WEEK 2: Generic Repository +3. WEEK 3: Order State Machine +4. WEEK 4: Payment Gateway +exit: 종료 + +> _ +``` + +--- + +## 🗓️ WEEK 1: Inventory System (상품 재고 관리) + +### 🎯 학습 목표 +- `readonly` 속성으로 불변성 보장 +- 구조적 타이핑과 잉여 속성 체크 +- `any` 타입 사용 금지 + +### 📥 입력 명령어 + +| 명령어 | 형식 | 설명 | +|--------|------|------| +| `add` | `add ` | 상품 등록 | +| `get` | `get ` | 상품 조회 | +| `list` | `list` | 전체 목록 조회 | +| `help` | `help` | 도움말 | +| `back` | `back` | 메인 메뉴로 | + +### 📤 출력 예시 + +#### ✅ 성공 케이스 + +**1. 상품 등록** +``` +> add PROD-001 노트북 1500000 electronics + +✅ 상품이 등록되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ID : PROD-001 +이름 : 노트북 +가격 : 1,500,000원 +카테고리 : electronics +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**2. 상품 조회** +``` +> get PROD-001 + +{ + id: 'PROD-001', + name: '노트북', + price: 1500000, + category: 'electronics' +} +``` + +**3. 전체 목록 조회** +``` +> list + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +총 2개의 상품 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[1] PROD-001 | 노트북 | 1,500,000원 | electronics +[2] PROD-002 | 마우스 | 50,000원 | electronics +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +#### ❌ 실패 케이스 + +**1. 중복 ID 등록 시도** +``` +> add PROD-001 키보드 100000 electronics + +❌ 오류: 이미 존재하는 상품 ID입니다. +``` + +**2. 존재하지 않는 상품 조회** +``` +> get PROD-999 + +undefined +``` + +**3. 잘못된 형식** +``` +> add PROD-001 노트북 + +❌ 오류: 인자가 부족합니다. 형식: add +``` + +### 🔍 검증 조건 + +#### REQ-001: 상품 등록 +- [x] 4개의 인자 모두 필수 (id, name, price, category) +- [x] price는 숫자로 변환 가능해야 함 +- [x] 동일 ID 재등록 시 에러 발생 + +#### REQ-002: 상품 조회 +- [x] 존재하는 ID → Product 객체 반환 +- [x] 존재하지 않는 ID → undefined 반환 + +#### REQ-003: 전체 목록 조회 +- [x] readonly 배열 반환 +- [x] 반환된 배열 수정 시도 시 TypeScript 컴파일 에러 + +#### REQ-004: 불변성 +```typescript +type Product = { + readonly id: string; + readonly name: string; + readonly price: number; + readonly category: string; +}; + +const product: Product = { id: 'P1', name: 'A', price: 100, category: 'C' }; +product.price = 200; // ❌ 컴파일 에러: Cannot assign to 'price' because it is a read-only property +``` + +#### REQ-005: 타입 안전성 +```typescript +// ❌ 금지: any 사용 +function addProduct(product: any) { ... } + +// ✅ 올바름: 명시적 타입 +function addProduct(product: Product): void { ... } +``` + +### 💻 핵심 타입 정의 +```typescript +type Product = { + readonly id: string; + readonly name: string; + readonly price: number; + readonly category: string; +}; + +class Inventory { + private products: Map = new Map(); + + addProduct(product: Product): void { + if (this.products.has(product.id)) { + throw new Error("이미 존재하는 상품 ID입니다."); + } + this.products.set(product.id, product); + } + + getProduct(id: string): Product | undefined { + return this.products.get(id); + } + + getAllProducts(): readonly Product[] { + return Array.from(this.products.values()); + } +} +``` + +--- + +## 🗓️ WEEK 2: Generic Repository (제네릭 저장소) + +### 🎯 학습 목표 +- 제네릭 타입 `` 사용 +- `Partial`, `keyof T` 활용 +- 타입 안전한 배열 조작 + +### 📥 입력 명령어 + +| 명령어 | 형식 | 설명 | +|--------|------|------| +| `create` | `create ` | Repository 생성 (type: product/order/user) | +| `save` | `save ` | 엔티티 저장 | +| `find` | `find ` | ID로 조회 | +| `findby` | `findby ` | 부분 쿼리 검색 | +| `list` | `list [sortBy] [order]` | 전체 조회 + 정렬 | +| `pluck` | `pluck ` | 특정 필드만 추출 | + +### 📤 출력 예시 + +#### ✅ 성공 케이스 + +**1. 저장** +``` +> save product {"id":"P1","name":"노트북","price":1500000,"category":"electronics"} + +✅ 저장 완료: P1 +``` + +**2. 부분 쿼리 검색** +``` +> findby product {"category":"electronics","price":1500000} + +검색 결과: 1건 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[ + { + id: 'P1', + name: '노트북', + price: 1500000, + category: 'electronics' + } +] +``` + +**3. 정렬된 목록 조회** +``` +> list product price desc + +가격 내림차순 정렬 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[1] P1 | 노트북 | 1,500,000원 +[2] P2 | 마우스 | 50,000원 +[3] P3 | 키보드 | 30,000원 +``` + +**4. 필드 추출 (pluck)** +``` +> pluck product name + +추출된 필드: name +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +['노트북', '마우스', '키보드'] +타입: string[] +``` + +#### ❌ 실패 케이스 + +**1. 존재하지 않는 필드로 검색** +```typescript +// TypeScript 컴파일 에러 +repo.findBy({ invalidField: "value" }); +// ❌ Error: Object literal may only specify known properties +``` + +**2. 잘못된 정렬 키** +```typescript +// TypeScript 컴파일 에러 +repo.findAll("nonExistentField", "asc"); +// ❌ Error: Argument of type '"nonExistentField"' is not assignable to parameter of type 'keyof Product' +``` + +**3. 잘못된 pluck 키** +```typescript +// TypeScript 컴파일 에러 +pluck(products, "invalidKey"); +// ❌ Error: Argument of type '"invalidKey"' is not assignable to parameter of type 'keyof Product' +``` + +### 🔍 검증 조건 + +#### REQ-101: 범용 저장소 +```typescript +interface Entity { + id: string; +} + +class Repository { + // T는 반드시 id: string 속성을 가져야 함 +} + +// ✅ 올바름 +interface Product extends Entity { + id: string; + name: string; + price: number; +} + +// ❌ 컴파일 에러 +interface InvalidType { + name: string; // id 속성 없음 +} +const repo = new Repository(); // Error! +``` + +#### REQ-102: 타입 안전한 검색 +```typescript +findBy(query: Partial): T[] { + // Partial는 T의 모든 속성을 선택적으로 만듦 + // 예: Partial = { id?: string; name?: string; price?: number; category?: string } +} + +// ✅ 올바름 +repo.findBy({ category: "electronics" }); +repo.findBy({ price: 50000, category: "electronics" }); + +// ❌ 컴파일 에러 +repo.findBy({ invalidField: "value" }); +``` + +#### REQ-103: 함수형 유틸리티 +```typescript +function pluck(items: T[], key: K): T[K][] { + return items.map(item => item[key]); +} + +const products: Product[] = [...]; +const names = pluck(products, "name"); // 타입: string[] +const prices = pluck(products, "price"); // 타입: number[] +``` + +#### REQ-104: 정렬 기능 +```typescript +findAll(sortBy?: keyof T, order?: 'asc' | 'desc'): T[] { + // keyof T는 T의 모든 키를 유니온 타입으로 만듦 + // 예: keyof Product = "id" | "name" | "price" | "category" +} + +// ✅ 올바름 +repo.findAll("price", "desc"); +repo.findAll("name", "asc"); + +// ❌ 컴파일 에러 +repo.findAll("invalidKey", "asc"); +``` + +### 💻 핵심 타입 정의 +```typescript +interface Entity { + id: string; +} + +class Repository { + private items: Map = new Map(); + + save(item: T): void { + this.items.set(item.id, item); + } + + findById(id: string): T | undefined { + return this.items.get(id); + } + + findBy(query: Partial): T[] { + return Array.from(this.items.values()).filter(item => { + return Object.entries(query).every(([key, value]) => { + return item[key as keyof T] === value; + }); + }); + } + + findAll(sortBy?: keyof T, order: 'asc' | 'desc' = 'asc'): T[] { + const items = Array.from(this.items.values()); + if (!sortBy) return items; + + return items.sort((a, b) => { + const aVal = a[sortBy]; + const bVal = b[sortBy]; + const result = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return order === 'asc' ? result : -result; + }); + } + + delete(id: string): boolean { + return this.items.delete(id); + } +} + +function pluck(items: T[], key: K): T[K][] { + return items.map(item => item[key]); +} +``` + +--- + +## 🗓️ WEEK 3: Order State Machine (주문 상태 관리) + +### 🎯 학습 목표 +- Discriminated Union 타입 활용 +- 타입 시스템으로 불가능한 상태 방지 +- 타입 안전한 상태 전이 함수 + +### 📥 입력 명령어 + +| 명령어 | 형식 | 설명 | +|--------|------|------| +| `create` | `create ` | 주문 생성 (pending) | +| `pay` | `pay ` | 결제 처리 (pending → paid) | +| `ship` | `ship ` | 배송 시작 (paid → shipped) | +| `deliver` | `deliver ` | 배송 완료 (shipped → delivered) | +| `cancel` | `cancel ` | 주문 취소 | +| `status` | `status ` | 주문 상태 조회 | + +### 📤 출력 예시 + +#### ✅ 성공 케이스 + +**1. 주문 생성** +``` +> create ORD-001 + +✅ 주문이 생성되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +상태 : pending (결제 대기 중) +주문 ID : ORD-001 +생성 시각 : 2024-12-13T15:30:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**2. 결제 처리** +``` +> pay ORD-001 card + +✅ 결제가 완료되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +상태 : paid (결제 완료) +주문 ID : ORD-001 +결제 수단 : card +결제 시각 : 2024-12-13T15:32:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**3. 배송 시작** +``` +> ship ORD-001 TRACK-12345 + +✅ 배송이 시작되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +상태 : shipped (배송 중) +주문 ID : ORD-001 +운송장번호 : TRACK-12345 +배송 시각 : 2024-12-13T15:35:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**4. 배송 완료** +``` +> deliver ORD-001 + +✅ 배송이 완료되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +상태 : delivered (배송 완료) +주문 ID : ORD-001 +완료 시각 : 2024-12-13T16:00:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**5. 주문 취소** +``` +> cancel ORD-002 고객 요청 + +✅ 주문이 취소되었습니다. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +상태 : cancelled +주문 ID : ORD-002 +취소 사유 : 고객 요청 +취소 시각 : 2024-12-13T15:40:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +#### ❌ 실패 케이스 (타입 시스템이 방지) + +**1. 결제 없이 배송 시도** +```typescript +const pendingOrder: PendingOrder = { ... }; +shipOrder(pendingOrder, "TRACK-123"); +// ❌ TypeScript 컴파일 에러: +// Argument of type 'PendingOrder' is not assignable to parameter of type 'PaidOrder' +``` + +**2. 배송 완료 후 되돌리기 시도** +```typescript +const deliveredOrder: DeliveredOrder = { ... }; +shipOrder(deliveredOrder, "TRACK-456"); +// ❌ TypeScript 컴파일 에러: +// Argument of type 'DeliveredOrder' is not assignable to parameter of type 'PaidOrder' +``` + +**3. 취소된 주문 복구 시도** +```typescript +const cancelledOrder: CancelledOrder = { ... }; +payOrder(cancelledOrder, "card"); +// ❌ TypeScript 컴파일 에러: +// Argument of type 'CancelledOrder' is not assignable to parameter of type 'PendingOrder' +``` + +### 🔍 검증 조건 + +#### REQ-201: 주문 상태 정의 (Discriminated Union) +```typescript +type PendingOrder = { + status: 'pending'; // discriminant + orderId: string; + items: string[]; + createdAt: string; +}; + +type PaidOrder = { + status: 'paid'; + orderId: string; + items: string[]; + createdAt: string; + paidAt: string; // ⭐ paid 상태에서만 존재 + paymentMethod: string; +}; + +type ShippedOrder = { + status: 'shipped'; + orderId: string; + items: string[]; + createdAt: string; + paidAt: string; + paymentMethod: string; + shippedAt: string; + trackingNumber: string; // ⭐ shipped 상태에서만 존재 +}; + +type DeliveredOrder = { + status: 'delivered'; + orderId: string; + items: string[]; + createdAt: string; + paidAt: string; + paymentMethod: string; + shippedAt: string; + trackingNumber: string; + deliveredAt: string; // ⭐ delivered 상태에서만 존재 +}; + +type CancelledOrder = { + status: 'cancelled'; + orderId: string; + createdAt: string; + cancelledAt: string; + cancelReason: string; +}; + +type Order = + | PendingOrder + | PaidOrder + | ShippedOrder + | DeliveredOrder + | CancelledOrder; +``` + +#### REQ-202: 상태 전이 규칙 + +**허용되는 전이 (✅)** +```typescript +// pending → paid +function payOrder(order: PendingOrder, method: string): PaidOrder { + return { + ...order, + status: 'paid', + paidAt: new Date().toISOString(), + paymentMethod: method, + }; +} + +// paid → shipped +function shipOrder(order: PaidOrder, trackingNumber: string): ShippedOrder { + return { + ...order, + status: 'shipped', + shippedAt: new Date().toISOString(), + trackingNumber, + }; +} + +// shipped → delivered +function deliverOrder(order: ShippedOrder): DeliveredOrder { + return { + ...order, + status: 'delivered', + deliveredAt: new Date().toISOString(), + }; +} + +// pending/paid → cancelled +function cancelOrder( + order: PendingOrder | PaidOrder, + reason: string +): CancelledOrder { + return { + status: 'cancelled', + orderId: order.orderId, + createdAt: order.createdAt, + cancelledAt: new Date().toISOString(), + cancelReason: reason, + }; +} +``` + +**금지되는 전이 (❌ 컴파일 에러)** +```typescript +// pending → shipped (결제 없이 배송 불가) +const pending: PendingOrder = { ... }; +shipOrder(pending, "TRACK-123"); // ❌ Error! + +// delivered → shipped (배송 완료 후 되돌릴 수 없음) +const delivered: DeliveredOrder = { ... }; +shipOrder(delivered, "TRACK-456"); // ❌ Error! + +// cancelled → paid (취소 후 복구 불가) +const cancelled: CancelledOrder = { ... }; +payOrder(cancelled, "card"); // ❌ Error! +``` + +#### REQ-203: 타입 안전한 상태 처리 + +**타입 좁히기 (Type Narrowing)** +```typescript +function processOrder(order: Order): void { + switch (order.status) { + case 'pending': + // 이 블록에서 order는 PendingOrder 타입 + console.log(order.orderId); + // console.log(order.trackingNumber); // ❌ Error: trackingNumber 없음 + break; + + case 'shipped': + // 이 블록에서 order는 ShippedOrder 타입 + console.log(order.trackingNumber); // ✅ OK + console.log(order.paidAt); // ✅ OK + break; + + case 'cancelled': + // 이 블록에서 order는 CancelledOrder 타입 + console.log(order.cancelReason); + // console.log(order.paidAt); // ❌ Error: paidAt 없음 + break; + } +} +``` + +### 💻 핵심 구현 +```typescript +class OrderManager { + private orders: Map = new Map(); + + createOrder(orderId: string): PendingOrder { + const order: PendingOrder = { + status: 'pending', + orderId, + items: [], + createdAt: new Date().toISOString(), + }; + this.orders.set(orderId, order); + return order; + } + + payOrder(orderId: string, paymentMethod: string): PaidOrder { + const order = this.orders.get(orderId); + if (!order) throw new Error("주문을 찾을 수 없습니다."); + if (order.status !== 'pending') { + throw new Error("결제 대기 중인 주문만 결제할 수 있습니다."); + } + + const paidOrder: PaidOrder = { + ...order, + status: 'paid', + paidAt: new Date().toISOString(), + paymentMethod, + }; + this.orders.set(orderId, paidOrder); + return paidOrder; + } + + shipOrder(orderId: string, trackingNumber: string): ShippedOrder { + const order = this.orders.get(orderId); + if (!order) throw new Error("주문을 찾을 수 없습니다."); + if (order.status !== 'paid') { + throw new Error("결제 완료된 주문만 배송할 수 있습니다."); + } + + const shippedOrder: ShippedOrder = { + ...order, + status: 'shipped', + shippedAt: new Date().toISOString(), + trackingNumber, + }; + this.orders.set(orderId, shippedOrder); + return shippedOrder; + } + + // ... deliverOrder, cancelOrder 구현 +} +``` + +--- + +## 🗓️ WEEK 4: Payment Gateway (결제 API 연동) + +### 🎯 학습 목표 +- `unknown` 타입 안전하게 처리 +- 타입 가드 (Type Guard) 구현 +- 타입 단언 (`as`) 사용 금지 + +### 📥 입력 명령어 + +| 명령어 | 형식 | 설명 | +|--------|------|------| +| `process` | `process ` | 결제 처리 | +| `mock` | `mock ` | 모의 응답 테스트 (success/fail/invalid) | + +### 📤 출력 예시 + +#### ✅ 성공 케이스 + +**1. 결제 성공** +``` +> process 50000 card + +🔄 외부 결제 API 호출 중... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +수신된 데이터 (unknown 타입): +{ + "success": true, + "transactionId": "TXN-2024-001", + "amount": 50000, + "method": "card", + "timestamp": "2024-12-13T15:30:00Z" +} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🔍 타입 가드 검증 중... +✅ PaymentSuccess 타입으로 확인됨 + +✅ 결제 성공 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +거래 ID : TXN-2024-001 +금액 : 50,000원 +결제 수단 : card +처리 시각 : 2024-12-13T15:30:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +**2. 결제 실패** +``` +> mock fail + +🔄 모의 실패 응답 생성 중... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +수신된 데이터 (unknown 타입): +{ + "success": false, + "errorCode": "INSUFFICIENT_FUNDS", + "errorMessage": "잔액 부족", + "timestamp": "2024-12-13T15:30:00Z" +} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🔍 타입 가드 검증 중... +✅ PaymentFailure 타입으로 확인됨 + +❌ 결제 실패 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +에러 코드 : INSUFFICIENT_FUNDS +에러 메시지: 잔액 부족 +처리 시각 : 2024-12-13T15:30:00Z +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +#### ❌ 실패 케이스 (타입 가드가 감지) + +**1. null/undefined 응답** +``` +> mock invalid + +🔄 모의 잘못된 응답 생성 중... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +수신된 데이터 (unknown 타입): +null +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🔍 타입 가드 검증 중... +❌ 타입 가드 실패: 응답이 null 또는 undefined입니다. +``` + +**2. 객체가 아닌 응답** +``` +수신된 데이터 (unknown 타입): +"error string" + +🔍 타입 가드 검증 중... +❌ 타입 가드 실패: 응답이 객체가 아닙니다. (타입: string) +``` + +**3. 필수 필드 누락** +``` +수신된 데이터 (unknown 타입): +{ + "success": true, + "amount": 50000 + // transactionId 누락! +} + +🔍 타입 가드 검증 중... +❌ 타입 가드 실패: 필수 필드 'transactionId'가 누락되었습니다. +``` + +**4. 필드 타입 불일치** +``` +수신된 데이터 (unknown 타입): +{ + "success": true, + "transactionId": 12345, // ❌ 숫자 (string이어야 함) + "amount": 50000, + "method": "card", + "timestamp": "2024-12-13T15:30:00Z" +} + +🔍 타입 가드 검증 중... +❌ 타입 가드 실패: 'transactionId' 필드의 타입이 올바르지 않습니다. (예상: string, 실제: number) +``` + +### 🔍 검증 조건 + +#### REQ-301: 결제 API 연동 +```typescript +async function processPayment( + amount: number, + method: string +): Promise { + // 외부 API 호출 (타입이 보장되지 않음) + const response: unknown = await fetch('/payment/process', { + method: 'POST', + body: JSON.stringify({ amount, method }) + }).then(res => res.json()); + + // ⭐ unknown 타입을 타입 가드로 검증 + if (isPaymentSuccess(response)) { + return { type: 'success', data: response }; + } else if (isPaymentFailure(response)) { + return { type: 'failure', data: response }; + } else { + throw new Error("알 수 없는 응답 형식"); + } +} +``` + +#### REQ-302: 타입 가드 구현 + +**타입 정의** +```typescript +type PaymentSuccess = { + success: true; + transactionId: string; + amount: number; + method: string; + timestamp: string; +}; + +type PaymentFailure = { + success: false; + errorCode: string; + errorMessage: string; + timestamp: string; +}; +``` + +**타입 가드 함수 (✅ 올바른 구현)** +```typescript +function isPaymentSuccess(data: unknown): data is PaymentSuccess { + // 1. null/undefined 체크 + if (data == null) { + return false; + } + + // 2. 객체 타입 검증 + if (typeof data !== 'object') { + return false; + } + + // 3. 필수 필드 존재 여부 + if (!('success' in data)) { + return false; + } + + // 4. success 필드 타입 검증 + if (typeof (data as any).success !== 'boolean') { + return false; + } + + // 5. success === true인 경우의 필수 필드 검증 + if ((data as any).success !== true) { + return false; + } + + const obj = data as any; + + return ( + 'transactionId' in obj && typeof obj.transactionId === 'string' && + 'amount' in obj && typeof obj.amount === 'number' && + 'method' in obj && typeof obj.method === 'string' && + 'timestamp' in obj && typeof obj.timestamp === 'string' + ); +} + +function isPaymentFailure(data: unknown): data is PaymentFailure { + if (data == null || typeof data !== 'object') { + return false; + } + + const obj = data as any; + + return ( + 'success' in obj && obj.success === false && + 'errorCode' in obj && typeof obj.errorCode === 'string' && + 'errorMessage' in obj && typeof obj.errorMessage === 'string' && + 'timestamp' in obj && typeof obj.timestamp === 'string' + ); +} +``` + +**❌ 금지되는 패턴** +```typescript +// ❌ 타입 단언 사용 금지 +function processPayment(response: unknown): PaymentSuccess { + return response as PaymentSuccess; // 위험! 런타임 검증 없음 +} + +// ❌ any 타입 사용 금지 +function processPayment(response: any): void { + console.log(response.transactionId); // 타입 안전성 상실 +} +``` + +#### REQ-303: 에러 처리 + +**모든 예외 케이스 처리** +```typescript +function safeProcessPayment(data: unknown): string { + // 케이스 1: null/undefined + if (data == null) { + return "❌ 응답이 null 또는 undefined입니다."; + } + + // 케이스 2: 객체가 아님 (string, number, array 등) + if (typeof data !== 'object') { + return `❌ 응답이 객체가 아닙니다. (타입: ${typeof data})`; + } + + // 케이스 3: 배열인 경우 + if (Array.isArray(data)) { + return "❌ 응답이 배열입니다. 객체가 필요합니다."; + } + + // 케이스 4: success 필드 누락 + if (!('success' in data)) { + return "❌ 필수 필드 'success'가 누락되었습니다."; + } + + // 케이스 5: success 필드 타입 불일치 + if (typeof (data as any).success !== 'boolean') { + return "❌ 'success' 필드의 타입이 boolean이 아닙니다."; + } + + // 케이스 6-7: 타입 가드로 정확한 타입 확인 + if (isPaymentSuccess(data)) { + return `✅ 결제 성공: ${data.transactionId}`; + } else if (isPaymentFailure(data)) { + return `❌ 결제 실패: [${data.errorCode}] ${data.errorMessage}`; + } else { + return "❌ 알 수 없는 응답 형식입니다."; + } +} +``` + +### 💻 핵심 구현 +```typescript +class PaymentGateway { + // 외부 API 시뮬레이션 + async callExternalAPI( + amount: number, + method: string + ): Promise { + // 실제로는 fetch를 사용하지만, 여기서는 랜덤 응답 생성 + const random = Math.random(); + + if (random > 0.7) { + // 성공 응답 + return { + success: true, + transactionId: `TXN-${Date.now()}`, + amount, + method, + timestamp: new Date().toISOString(), + }; + } else if (random > 0.4) { + // 실패 응답 + return { + success: false, + errorCode: "INSUFFICIENT_FUNDS", + errorMessage: "잔액 부족", + timestamp: new Date().toISOString(), + }; + } else if (random > 0.2) { + // 잘못된 형식 (배열) + return ["error"]; + } else { + // null + return null; + } + } + + async processPayment( + amount: number, + method: string + ): Promise { + const response: unknown = await this.callExternalAPI(amount, method); + + // 타입 가드로 안전하게 처리 + if (isPaymentSuccess(response)) { + return { + type: 'success', + message: `결제 성공: ${response.transactionId}`, + data: response, + }; + } else if (isPaymentFailure(response)) { + return { + type: 'failure', + message: `결제 실패: [${response.errorCode}] ${response.errorMessage}`, + data: response, + }; + } else { + return { + type: 'error', + message: this.getErrorMessage(response), + }; + } + } + + private getErrorMessage(data: unknown): string { + if (data == null) { + return "응답이 null 또는 undefined입니다."; + } + if (typeof data !== 'object') { + return `응답이 객체가 아닙니다. (타입: ${typeof data})`; + } + if (Array.isArray(data)) { + return "응답이 배열입니다."; + } + return "알 수 없는 응답 형식입니다."; + } +} + +type PaymentResult = + | { type: 'success'; message: string; data: PaymentSuccess } + | { type: 'failure'; message: string; data: PaymentFailure } + | { type: 'error'; message: string }; +``` + +--- + +## 🚀 구현 순서 가이드 + +### 1단계: 프로젝트 구조 생성 +``` +mission-template/ +├── src/ +│ ├── index.ts # 메인 CLI 진입점 +│ ├── week1/ +│ │ ├── types.ts # Product, Inventory 타입 +│ │ └── cli.ts # WEEK 1 CLI 처리 +│ ├── week2/ +│ │ ├── types.ts # Entity, Repository 타입 +│ │ └── cli.ts # WEEK 2 CLI 처리 +│ ├── week3/ +│ │ ├── types.ts # Order 상태 타입들 +│ │ └── cli.ts # WEEK 3 CLI 처리 +│ └── week4/ +│ ├── types.ts # Payment 타입, 타입 가드 +│ └── cli.ts # WEEK 4 CLI 처리 +├── package.json +└── tsconfig.json +``` + +### 2단계: 각 주차별 구현 +1. types.ts 먼저 작성 (타입 정의) +2. 비즈니스 로직 구현 (클래스/함수) +3. cli.ts 작성 (명령어 파싱 및 처리) +4. index.ts에 메뉴 통합 + +### 3단계: 테스트 +- 각 명령어를 직접 실행하며 검증 +- 에러 케이스도 반드시 테스트 +- TypeScript 컴파일 에러가 예상대로 발생하는지 확인 + +--- + +## 📝 체크리스트 + +### WEEK 1 +- [ ] Product 타입의 모든 필드가 readonly +- [ ] any 타입 사용하지 않음 +- [ ] 동일 ID 재등록 시 에러 발생 +- [ ] getAllProducts()가 readonly 배열 반환 + +### WEEK 2 +- [ ] Repository 제약 조건 작동 +- [ ] findBy()가 Partial 사용 +- [ ] 존재하지 않는 필드 검색 시 컴파일 에러 +- [ ] pluck() 반환 타입이 T[K][]로 정확히 추론 +- [ ] sortBy가 keyof T 사용 + +### WEEK 3 +- [ ] 5가지 주문 상태 모두 정의 +- [ ] Discriminated Union (status 필드로 구분) +- [ ] pending → shipped 시도 시 컴파일 에러 +- [ ] shipped 상태는 trackingNumber 필수 +- [ ] cancelled 상태에서 다른 상태로 전이 불가 + +### WEEK 4 +- [ ] API 응답을 unknown으로 받음 +- [ ] as 타입 단언 사용하지 않음 +- [ ] isPaymentSuccess, isPaymentFailure 타입 가드 구현 +- [ ] null/undefined/배열/잘못된 필드 모두 처리 +- [ ] 타입 가드 실패 시 명확한 에러 메시지 + +--- + +## 💡 핵심 학습 포인트 + +### WEEK 1: readonly와 불변성 +- **왜 중요한가?** 데이터가 실수로 변경되는 것을 컴파일 타임에 방지 +- **실무 적용:** Redux state, React props, API 응답 객체 + +### WEEK 2: 제네릭과 타입 연산자 +- **왜 중요한가?** 코드 재사용성 + 타입 안전성 동시 확보 +- **실무 적용:** ORM, API 클라이언트, 유틸리티 함수 + +### WEEK 3: Discriminated Union +- **왜 중요한가?** 불가능한 상태를 타입 시스템으로 원천 차단 +- **실무 적용:** 상태 머신, 폼 단계, API 응답 처리 + +### WEEK 4: unknown과 타입 가드 +- **왜 중요한가?** 외부 데이터를 안전하게 처리 +- **실무 적용:** API 연동, JSON 파싱, 사용자 입력 검증 + +--- + +**이제 각 주차의 구현을 시작하세요!** 🚀 diff --git a/docs/week1/item11-14.md b/docs/week1/item11-14.md new file mode 100644 index 0000000..332b295 --- /dev/null +++ b/docs/week1/item11-14.md @@ -0,0 +1,850 @@ +# Item 11 잉여 속성 체크의 한계 인지하기 + +## Excess Property Checks + +- typescript는 필수조건만 충족한다면 **또 다른 어떤 속성**을 가지는 모든 객체는 해당 타입의 범위에 속함 (구조적 타이핑) + +```ts +interface Options { + title: string; + darkMode?: boolean; +} + +function createWindow(options: Options): void { + const { title, darkMode = false } = options; + console.log(`Creating window with title: ${title}`); + if (darkMode) { + console.log("Dart mode is enabled."); + } else { + console.log("Dart mode is disabled."); + } +} + +createWindow({ title: "My App" }); +// 1. 문제 될 것 없는 코드지만 에러 +// Object literal may only specify known properties, but 'darkmode' does not exist in type 'Options'. Did you mean to write 'darkMode'?ts(2561) +createWindow({ title: "My App", darkmode: true }); // 문제 될 것 없는 코드 +const opts = { title: "My App", darkmode: true }; +// 2. 에러도 발생 안함 +createWindow(opts); +``` + +- 하지만 typescript에서는 **잉여 속성 검사** 또는 [초과 프로퍼티 검사(Excess Property Checks)](https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EA%B0%9D%EC%B2%B4-%ED%83%80%EC%9E%85-%EC%B2%B4%ED%82%B9-%EC%9B%90%EB%A6%AC-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0#%EC%B4%88%EA%B3%BC_%ED%94%84%EB%A1%9C%ED%8D%BC%ED%8B%B0_%EA%B2%80%EC%82%AC_excess_property_checks)를 통해서 초과한 프로퍼티에 대한 검사를 해주게 된다. + - 그리고 초과프로퍼티 중에서 다른 특정한 프로퍼티가 값이 들어오지 않은게 있다면 해당 값을 의미했던 것은 아닌지?에 대한 추론도 해준다. + - 그러나 이러한 **초과 프로퍼티 검사**는 객체 리터럴(중괄호를 사용해 데이터를 구조화하여 즉시 객체를 만드는 방법)에만 동작한다. (2번 케이스의 경우 동작 안함) + +### Exactly Type + +- 이를 막기 위한 꼼수로 Exactly custom type을 사용하는 방법이 있다. + +```ts +interface Options { + title: string; + darkMode?: boolean; +} + +type Exactly = T & Record, never>; + +function createWindowExact>(options: T): void { + const { title, darkMode = false } = options; + console.log(`Creating window with title: ${title}`); + if (darkMode) { + console.log("Dart mode is enabled."); + } else { + console.log("Dart mode is disabled."); + } +} + +// 1. 에러 발생: 'darkmode' 속성은 'never' 타입과 호환되지 않습니다. +createWindowExact({ title: "My App", darkmode: true }); + +const opts = { title: "My App", darkmode: true }; +// 2. 이제 이 경우에도 에러가 발생합니다. +createWindowExact(opts); +``` + +> Exclude: U 타입에는 있지만, T 타입에는 없는 속성 키를 찾아냄 (Exclude : 타입 A에서 입 타입B에 속하는 멤버 제외)
+> Record: 키 K와 값 V를 가지는 객체 타입을 생성하는 유틸리티
+> never: TypeScript에서 절대 발생하지 않는 타입(공집합)
+ +> Exclude의 결과는 Exclude<("title" | "darkmode"), ("title" | "darkMode")>가 됨 -> 결과적으로 남는 것은 "darkmode"
+> 잉여 속성들의 타입(never 타입)을 강제로 지정하는 객체 타입을 만듬
+> Record<"darkmode", nver>의 결과는 darkmode: nver 타입이 됨 +> 두 타입을 교차하여 결합 (T & { darkmode: never }) -> 공집합에 값이 들어가면서 타입 에러 발생! + + +- 초과 프로퍼티 검사는 타입 단언문을 사용할 때에도 적용되지 않습니다. + +```ts +interface Options { + title: string; + darkMode?: boolean; +} + +// 에러 발생 안함 +const o: Options = { darkmode: true, title: 'Ski Free'} as Options; +``` + +## 약한 타입 + +- 선택적 속성만 가지는 **약한(weak) 타입에도 비슷한 체크가 동작합니다. + +```ts +interface Person { + name?: string; + age?: number; +} + +// 에러 발생 +const p1: Person = { Name: "Alice", Age: 30 }; +const p2 = { Name: "Bob", Age: 25 }; +// 객체 리터럴이 아닌데도 에러 발생 +const p3: Person = p2; +console.log(p1.name, p3.name); +``` + +- typescript 팀은 약한 타입 변수에 객체를 할당할 때는, 할당되는 객체의 속성 중 최소 하나 이상이 약한 타입 인터페이스의 속성과 일치해야 한다고 설명합니다. + +```ts +interface Person { + name?: string; + age?: number; +} + +const o2 = { name: "Bob", Age: 25 }; +// 에러 발생 안함 +const p4: Person = o2; +``` + +> Excess Property Checking and Weak Types in TypeScript are discussed in the official TypeScript Handbook's "Object Types" section and the TypeScript 2.4 release notes. The weak type checking behavior is a standard part of TypeScript, also referenced in various blogs and Stack Overflow answers. You can find more information on the [Microsoft TypeScript documentation](https://www.google.com/search?q=chrome&rlz=1C5CHFA_enKR1180KR1180&sourceid=chrome&ie=UTF-8&udm=50&aep=48&cud=0&qsubts=1766412205579&mstk=AUtExfCPfskDNMs_52KZldAay6X0F7cu_jggbdoVbvu-1yn15VL87yePp5G2z06i0I7oBUsUQCy8rQgTFT5zPw_T6tZa3sbBrMNAC-zV8NptMnGLw4eM4kONQUzHZdrvMHfEtlLOx26F-FkIg5YhQuVUt6X-RVECl0ngfwtcE40zQsdOSiTODuflyTsP2P37VVts6cTF-gRJGb9cVd6JXjI4wMb7Fss10MPjg2S3WMvgTqjXeC7vS-GmjsRY6Q&csuir=1) and resources. + +# Item 12 함수 표현식에 타입 적용하기 + +- 동일한 시그니처가 반복되는 함수에 대해서 type을 정의함으로써 type 선언 시간을 줄일 수 있다. + +```ts +type AsyncMiddleware = (req: Request, res: Response, next: NextFunction) => Promise; + +const authGuard: AsyncMiddleware = async (req, res, next) => { /* 인증 로직 */ }; +const loggingMiddleware: AsyncMiddleware = async (req, res, next) => { /* 로그 로직 */ }; +const rateLimiter: AsyncMiddleware = async (req, res, next) => { /* 제한 로직 */ }; +``` + +- 다른 함수의 시그니처를 참조하려면 **typeof fn**을 사용할 수 있다. + +```ts +function registerUser(name: string, age: number, isAdmin: boolean) { + // 유저 등록 로직... +} + +// registerUser의 인자들과 똑같은 인자를 받는 대기 함수(Proxy) 만들기 +type RegisterParams = Parameters; + +const loggerBeforeRegister = (...args: RegisterParams) => { + console.log("등록 시도 데이터:", args); + // registerUser(...args); // 실제 함수 호출 시에도 완벽하게 타입 호환 +}; +``` + +- 특정 함수의 인자만 가져오기 위해 **Parameters**를 사용할 수 있다. + +```ts +type calc = (x: number, y: number) => number; + +const add: calc = (x, y) => x + y; +console.log(add(2, 3)); + +type AddParameters = Parameters; + +const addprinter = (...args: AddParameters) => { + console.log(`Adding ${args[0]} and ${args[1]} gives ${add(...args)}`); +}; + +addprinter(5, 10); +``` + +- 특정 함수의 반환 값을 Type으로 쓰기 위해 **ReturnType**을 사용할 수 있다. + +```ts +type calc = (x: number, y: number) => number; +const add: calc = (x, y) => x + y; + +// 1. add 함수의 반환 타입을 추출 (여기서는 number가 됩니다) +type AddResult = ReturnType; + +// 2. 추출한 타입을 변수나 함수의 인자에 활용 +const saveResult = (result: AddResult) => { + console.log(`결과값 ${result}를 데이터베이스에 저장했습니다.`); +}; + +const result: AddResult = add(10, 20); +saveResult(result); +``` + +## 코드의 시그니처를 강제해서 의도치 않은 에러를 컴파일 타임에 잡아낼 수 있습니다. + +- /quote가 존재하지 않는 API라면 '404 Not Found'가 포함된 내용을 응답하며, 이는 JSON 형식이 아닐 수 있습니다. + +```ts +async function getQuote() { + const response = await fetch('/quote?by=Mark+Twain'); + const quote = await response.json(); + return quote; +} + +// 결과 예시 +// { +// "quote": "If you tell the truth, you don't have to remember anything.", +// "source": "notebook", +// "date": "1894" +// } +``` + +- 상태 체크를 수행하는 checkedFetch를 함수 문장으로 작성하면 다음과 같습니다 + +```ts +async function checkedFetch(input: RequestInfo, init?: RequestInit) { + const response = await fetch(input, init); + if (!response.ok) { + // 비동기 함수 내에서 거절된 프로미스로 변환합니다. + throw new Error('Request failed: ' + response.status); + } + return response; +} +``` + +- 함수 문장을 함수 표현식으로 바꾸고 함수 전체에 타입(typeof fetch)을 적용하면 더 간결해집니다. + +```ts +const checkedFetch: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + if (!response.ok) { + throw new Error('Request failed: ' + response.status); + } + return response; +} +``` + +- Error에 throw 대신 return을 사용했다면, 타입스크립트는 반환 타입이 fetch의 시그니처와 일치하지 않는다는 실수를 즉시 잡아냅니다. + + +# Item 13 타입과 인터페이스의 차이점 + + +## 공통점 + +- 인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있습니다. +- 함수 타입도 인터페이스나 타입으로 정의할 수 있습니다. +- 타입 별칭과 인터페이스는 모두 제너릭이 가능합니다. + +```ts +// 1. Type Alias로 정의한 TState +type TState = { + name: string; + capital: string; +}; + +// 2. Interface로 정의한 IState +interface IState { + name: string; + capital: string; +} + +type TPair = { + first: T; + second: T; +} + +interface IPair = { + first: T; + second: T; +} +``` + +- 인터페이스는 타입을 확장할 수 있고 타입은 인터페이스를 확장할 수 있다. + +```ts +interface IStateWithPop extends TState { + population: number; +} +type TStateWithPop = IState & { population: number; }; +``` + +- 클래스를 구현(implements)할 때는, 타입(TState)과 인터페이스(IState) 둘 다 사용할 수 있습니다. + +```ts +// 1. Type Alias로 정의한 TState +type TState = { + name: string; + capital: string; +}; + +// 2. Interface로 정의한 IState +interface IState { + name: string; + capital: string; +} + +class StateT implements TState { + ... +} + +class StateI implements IState { + ... +} +``` + + +## 차이점 + +- 유니온 타입은 있지만 **유니온 인터페이스라는 개념은 없습니다**. + - type 키워드는 일반적으로 interface보다 쓰임새가 많습니다. +- 인터페이스 내부에는 메서드가 존재할 수 없습니다. + - 인터페이스는 객체의 구조(데이터)만 정의할 뿐, Array.prototype의 기능(메서드)을 상속받지 않았습니다. + +```ts +// 튜플과 구조가 "비슷한" 인터페이스 +interface ITuple { + 0: string; + 1: number; + length: 2; +} + +const myTuple: ITuple = ["hello", 123]; // 구조적으로는 맞아서 할당 가능 (Duck Typing) + +// ❌ 에러 발생! +// Property 'concat' does not exist on type 'ITuple'. +myTuple.concat([456]); + +// 타입 별칭을 이용한 진짜 튜플 정의 +type TTuple = [string, number]; + +const myTuple2: TTuple = ["hello", 123]; + +// ✅ 성공! +// TypeScript는 이것이 Array의 하위 타입임을 알고 있습니다. +const result = myTuple2.concat(["world"]); +console.log(result); // ["hello", 123, "world"] +``` + +- interface는 type에는 존재하지 않는 **보강(augment)이 가능**하다. + - 속성을 확장하는 것을 **선언 병합(declaration merging)**이라고 한다. + - 예를 들어, Array 인터페이스는 [lib.es5.d.ts](https://github.com/microsoft/TypeScript/blob/2dfdbbabae955186f821925c629a37d8df76bab2/src/lib/es5.d.ts#L1307)에 정의되어 있는데, ES2015를 추가하면 [lib.es2015.d.ts](https://github.com/microsoft/TypeScript/blob/2dfdbbabae955186f821925c629a37d8df76bab2/src/lib/es2015.iterable.d.ts#L58)에 선언된 인터페이스를 병함한다. + +```ts +interface IState { + name: string; + capital: string; +} +interface IState { + population: number; +} + +const wyoming: IState = { + name: "Wyoming", + capital: "Cheyenne", + population: 500_000, +}; + +const keys = Object.keys(wyoming); + +// 배열을 순회하며 출력 +keys.forEach((key) => { + console.log(key); +}); + +// 출력 결과: +// "name" +// "capital" +// "population" +``` + +- 일관되게 인터페이스를 사용하는 코드베이스에서 작업하고 있다면 인터페이스를 사용하고, 일관되게 타입을 사용 중이라면 타입을 사용하는게 좋다. +- 어떤 API에 대한 타입 선언을 작성해야 한다면 인터페이스를 사용하는게 좋다. + - API가 변경될 때 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하기 때문 + + +## 인덱스 시그니처 + +- **인덱스 시그니처**(Index Signature)는 Typescript에서 "객체의 속성(Key) 이름은 정확히 모르지만, 그 속성들의 타입(Value)은 알고 있을 때" 사용하는 문법입니다. + +```ts +interface StringArray { + // index는 어떤걸로든 명칭을 붙일 수 있습니다. + [index: number]: string; +} + +const myArray: StringArray = ["Bob", "Fred"]; +const myStr: string = myArray[0]; // index(0)은 number, 값("Bob")은 string +``` + +- 인덱스 시그니처가 있으면, 명시적으로 선언된 다른 속성들도 반드시 그 규칙을 따라야 합니다. + +```ts +interface Dictionary { + [key: string]: number; // "모든 문자열 속성의 값은 숫자여야 한다"고 선언함 + + length: number; // (O) number라서 통과 + name: string; // (X) Error! 'string'은 인덱스 시그니처의 'number'에 할당할 수 없음 +} +``` + +- 만약 name처럼 다른 타입이 꼭 필요하다면 유니온 타입(|)을 써야 합니다. + +```ts +interface Dictionary { + [key: string]: number | string; // 값은 숫자거나 문자일 수 있다 + name: string; // (O) 이제 통과됨 +} +``` + +## 프로토타입 체인 + +자바스크립트라는 언어의 가장 핵심이면서도, 초보자들이 가장 헷갈려 하는 **'프로토타입 체인(Prototype Chain)'** 개념을 아주 쉬운 비유를 들어 설명해 드릴게요. + +코딩 용어는 잠시 잊으세요. 우리는 지금부터 **"물건 빌리기"** 놀이를 할 겁니다. + +--- + +### 1. 상황 설정: 내 가방과 아빠의 가방 + +당신(**자식**)이 있고, 당신의 **아빠**, 그리고 **할아버지**가 있다고 상상해 보세요. 그리고 각자 자기만의 **가방(객체, Object)**을 하나씩 메고 있습니다. + +* **나의 가방:** `[스마트폰, 지갑]` +* **아빠의 가방:** `[자동차 키, 망치]` +* **할아버지의 가방:** `[고문서, 돋보기]` + +자바스크립트의 세계에서는 이 세 사람을 **끈**으로 묶어 놓을 수 있습니다. 이것이 바로 **"연결(Chain)"**입니다. + +> **나 —(연결)—> 아빠 —(연결)—> 할아버지** + +### 2. 프로토타입 체인의 작동 원리 (물건 찾기) + +이제 당신이 어떤 물건을 쓰고 싶어 한다고 칩시다. 자바스크립트는 당신이 물건을 찾을 때 다음과 같은 **3단계 규칙**으로 움직입니다. + +#### 상황 1: 내가 가진 것을 찾을 때 + +"나는 **스마트폰**을 쓰고 싶어!" + +1. 내 가방을 뒤집니다. +2. "어! 스마트폰 있네?" +3. **성공!** 바로 사용합니다. (아빠나 할아버지를 귀찮게 할 필요가 없습니다.) + +#### 상황 2: 내가 없지만, 아빠가 가진 것을 찾을 때 (여기가 핵심!) + +"나는 **망치**가 필요해!" + +1. 내 가방을 뒤집니다. (없음) +2. 포기할까요? 아닙니다. 나랑 연결된 **아빠(프로토타입)**한테 갑니다. +3. 아빠 가방을 뒤집니다. "어! 아빠 가방에 망치가 있네?" +4. **성공!** 아빠의 망치를 빌려 씁니다. +* *마치 내 것처럼 자연스럽게 씁니다.* + + + +#### 상황 3: 아빠도 없어서, 할아버지까지 갈 때 + +"나는 **돋보기**가 필요해!" + +1. 내 가방 뒤짐. (없음) +2. 아빠한테 감. 아빠 가방 뒤짐. (없음) +3. 아빠랑 연결된 **할아버지(아빠의 프로토타입)**한테 갑니다. +4. 할아버지 가방 뒤짐. "찾았다! 돋보기!" +5. **성공!** + +### 3. 이게 바로 '프로토타입 체인'입니다 + +위의 이야기에서 나온 개념을 자바스크립트 용어로 바꾸면 이렇습니다. + +1. **가방 = 객체 (Object):** 데이터가 들어있는 통입니다. +2. **아빠 = 프로토타입 (Prototype):** 나에게 없는 능력을 대신 제공해 주는 **'원형(부모)'**입니다. +3. **끈으로 연결됨 = 체인 (Chain):** 내가 못 찾으면 자동으로 부모를 찾아가도록 연결된 구조입니다. + +즉, **프로토타입 체인**이란 **"내가 능력이 없을 때, 내 부모(프로토타입)의 능력을 빌려 쓰기 위해 줄줄이 사탕처럼 연결된 구조"**를 말합니다. + +### 4. 실제 코드로 느낌만 보기 (아주 간단히) + +자바스크립트로는 이 상황을 이렇게 짭니다. (문법은 몰라도 됩니다. 흐름만 보세요!) + +```javascript +// 1. 할아버지 (돋보기를 가짐) +const grandFather = { 돋보기: "오래된 돋보기" }; + +// 2. 아빠 (망치를 가짐) +const father = { 망치: "튼튼한 망치" }; +// 아빠의 부모는 할아버지라고 연결! +father.__proto__ = grandFather; + +// 3. 나 (스마트폰을 가짐) +const me = { 스마트폰: "아이폰" }; +// 나의 부모는 아빠라고 연결! +me.__proto__ = father; + +// ------------------------------------------ + +// Q: 나는 돋보기가 있나요? (내 코드엔 없죠?) +console.log(me.돋보기); + +// 결과: "오래된 돋보기"가 출력됩니다! +// (나 -> 아빠 -> 할아버지 순서로 찾아가서 가져옴) + +``` + +### 5. 체인의 끝은 어디인가요? (중요) + +할아버지의 아빠, 증조할아버지... 계속 올라가다 보면 끝이 있겠죠? + +자바스크립트의 모든 객체(가방)의 조상을 계속 따라 올라가면, 결국 **`Object.prototype`**이라는 '최초의 조상'을 만나게 됩니다. (마치 인류의 시초인 아담과 이브처럼요.) + +만약 이 최초의 조상 가방에도 없는 물건(예: `레이저총`)을 달라고 하면 어떻게 될까요? +더 이상 물어볼 부모가 없으므로, 자바스크립트는 비로소 **"없습니다(undefined)"**라고 대답합니다. + +### 6. 그렇다면 인터페이스는?? + +> TypeScript는 Structural Typing(구조적 타이핑) 시스템을 사용하고, 인터페이스는 그 시스템 안에서 객체의 구조(Shape)를 정의하는 도구다. + +**"객체의 구조(데이터)만 정의했다"**는 말을 아주 쉬운 비유로 설명해 드릴게요. + +휴대폰 대리점에 가면 진열대에 놓인 **'목업폰(모형 휴대폰)'**을 보신 적 있나요? 이 목업폰이 바로 **인터페이스**와 똑같습니다. + +--- + +#### 1. 비유: 진짜 스마트폰 vs 목업폰 + +##### (1) 목업폰 (인터페이스) + +목업폰을 손에 들어보세요. + +* **겉모양(구조)**은 진짜랑 똑같습니다. +* 앞면에 화면이 있고, 옆에 볼륨 버튼이 있고, 뒷면에 카메라 렌즈가 있습니다. +* 누가 봐도 "아, 이건 스마트폰 모양이네"라고 알 수 있습니다. + +**하지만 전원 버튼을 눌러보세요.** 화면이 켜지나요? 전화를 걸 수 있나요? +**아니요, 안 됩니다.** 왜냐하면 안에는 배터리도, 반도체(CPU)도, **기능을 작동시키는 기계 장치(엔진)**가 전혀 없기 때문입니다. 그저 껍데기일 뿐이죠. + +> **이게 바로 "구조만 정의했다"는 뜻입니다.** +> "이 객체는 화면이 있어야 하고, 버튼이 있어야 해"라는 **모양 규칙**은 지켰지만, 실제로 전화를 거는 **기능(메서드)**은 없다는 것이죠. + +##### (2) 진짜 스마트폰 (Array.prototype을 상속받은 진짜 배열) + +진짜 폰은 겉모양도 갖췄고, 그 안에 **삼성이나 애플이 심어놓은 소프트웨어(기능)**가 들어있습니다. + +* 이 소프트웨어가 있기 때문에 전화를 걸고(`call`), 문자를 보내고(`send`), 앱을 켤 수 있습니다. +* 이 '심어놓은 소프트웨어'가 바로 프로그래밍 용어로 **`Array.prototype`**입니다. + +--- + +#### 2. 코드로 다시 보기 + +질문하신 상황으로 돌아가 볼까요? + +**인터페이스로 만든 튜플 (목업폰)** + +```typescript +interface FakeTuple { + 0: string; // 첫 번째 칸(화면) 있음 + 1: number; // 두 번째 칸(버튼) 있음 + length: 2; // 길이(크기)도 맞음 +} + +``` + +* **상황:** 개발자가 "자, 여기 0번 데이터 있고, 1번 데이터 있고, 길이도 2야. 그러니까 이건 배열(튜플)이지?"라고 우기는 상황입니다. +* **현실:** TypeScript는 "어, 모양은 배열이랑 똑같이 생겼네(구조는 맞네). 근데 안에 **배열 엔진(Array.prototype)**이 안 들어있잖아?"라고 판단합니다. +* **결과:** 모양은 배열 같지만, 배열의 엔진이 없으므로 `concat`(붙이기), `push`(밀어넣기) 같은 **기능 버튼을 눌러도 아무 반응이 없거나 에러가 나는 것**입니다. + +--- + +#### 3. 세 줄 요약 + +1. **객체의 구조만 정의했다**: "이건 자동차 모양 장난감이야. 바퀴 4개 있고 핸들 있어." (**껍데기 규격**) +2. **프로토타입 기능이 없다**: "근데 엔진이 없어서 실제로 굴러가진 않아." (**실제 작동 능력 부재**) +3. **결론**: 인터페이스로 튜플을 만들면 **'모양'은 흉내 낼 수 있지만, 배열이 가진 편리한 '기능(메서드)'들은 쓸 수 없습니다.** + +### 요약 + +자바스크립트를 모르는 생초보를 위한 한 줄 요약: + +> **"프로토타입 체인이란, 내 주머니에 없는 물건을 찾기 위해 아빠 주머니, 할아버지 주머니를 차례대로 뒤지는 '자동 검색 시스템'이다."** + +이 시스템 덕분에 우리는 모든 기능을 내 가방에 다 넣고 다닐 필요 없이, 부모(프로토타입)가 가진 기능을 편하게 내 것처럼 가져다 쓸 수 있는 것입니다. + +# Item 14 타입 연산과 제너릭 사용으로 반복 줄이기 + +- 타입 중복은 코드 중복만큼 많은 문제를 발생시킵니다. +- 타입 연산과 제너릭 타입을 사용해서 반복을 줄일 수 있습니다. + +## Pick Type + +```ts +type Pick = { [k in K]: T[k] }; + +type Person = { + head: string; + body: string; +} + +type Airhead = Pick; + +const airhead: Airhead = { + body: 'This is the body of the airhead person.' +}; +``` + +## [Partial Type](https://github.com/microsoft/TypeScript/blob/2dfdbbabae955186f821925c629a37d8df76bab2/src/lib/es5.d.ts#L1567) + +`Partial`는 타입스크립트의 유틸리티 타입 중 가장 많이 쓰이는 것으로, **"모든 속성을 선택적(Optional)으로 만들어주는 도구"**입니다. + +쉽게 말해, 필수 입력 항목이었던 것들을 모두 **"입력해도 되고 안 해도 되는 것"**으로 바꿔줍니다. + +--- + +### 1. 코드 상세 해석 + +```typescript +type Partial = { + [P in keyof T]?: T[P]; +}; + +``` + +이 코드는 **맵드 타입(Mapped Type)**이라는 문법을 사용합니다. 마치 `for` 문으로 객체의 속성을 하나씩 도는 것과 같습니다. + +1. **`T`**: 변환하고 싶은 **원본 타입**입니다. (예: `User` 인터페이스) +2. **`keyof T`**: 원본 타입 `T`가 가진 **모든 키(속성 이름)들의 목록**을 뽑아옵니다. +* 예: `User`가 `name`, `age`를 가졌다면 `keyof User`는 `"name" | "age"`가 됩니다. +3. **`[P in keyof T]`**: 뽑아온 키 목록을 하나씩 순회합니다. (반복문) +* `P`는 현재 순회 중인 속성 이름(Key)입니다. +4. **`?`**: **핵심입니다.** 현재 속성 `P` 뒤에 물음표를 붙여서 **"있어도 되고 없어도 됨(Optional)"**으로 만듭니다. +5. **`T[P]`**: 속성의 **값(Value) 타입**은 원본(`T`)에 있던 것(`T[P]`)을 그대로 씁니다. + +**결론:** 원본의 키와 값 타입은 유지하되, 모든 속성에 `?`를 붙여서 재탄생시킵니다. + +--- + +### 2. 실제 동작 예시 (Before & After) + +어떤 상황에서 `Partial`이 동작하는지 시뮬레이션해 보겠습니다. + +#### 1) 원본 타입 (모두 필수) + +```typescript +interface User { + name: string; + age: number; + email: string; +} + +// ❌ 에러 발생! (모든 속성이 다 있어야 함) +const user1: User = { + name: "Cheolsu" +}; + +``` + +#### 2) `Partial` 적용 (모두 선택) + +`Partial`를 쓰면 타입스크립트 내부적으로 아래와 같이 변환됩니다. + +```typescript +// 내부적으로 이렇게 변환됨 +// type PartialUser = { +// name?: string; +// age?: number; +// email?: string; +// } + +// ✅ 성공! (일부만 있어도 됨) +const user2: Partial = { + name: "Cheolsu" +}; + +// ✅ 성공! (아예 비어있어도 됨) +const user3: Partial = {}; +``` + +--- + +### 3. 실무에서는 언제 쓰나요? + +가장 대표적인 사용 사례는 **"데이터 수정(Update)"** 기능을 만들 때입니다. + +회원 정보를 수정할 때, 이름만 바꿀 수도 있고 나이만 바꿀 수도 있죠? 사용자가 무엇을 수정할지 모르기 때문에, 수정 함수를 만들 때는 모든 인자를 `Partial`로 받습니다. + +```typescript +interface Todo { + title: string; + description: string; + completed: boolean; +} + +// 할 일을 수정하는 함수 +// fieldsToUpdate: Todo의 속성 중 '일부'만 들어옵니다. +function updateTodo(todo: Todo, fieldsToUpdate: Partial) { + return { ...todo, ...fieldsToUpdate }; +} + +const myTodo: Todo = { title: "청소하기", description: "방 쓸고 닦기", completed: false }; + +// 1. 'completed'만 수정하고 싶을 때 +updateTodo(myTodo, { completed: true }); + +// 2. 'title'과 'description'만 수정하고 싶을 때 +updateTodo(myTodo, { title: "빨래하기", description: "흰 옷만" }); +``` + +사용자님의 정리 스타일(헤더 구조, 설명 방식, 코드 주석 스타일, '쉽게 말해' 등의 화법)을 분석하여, **Item 14**의 연장선상에서 **Pick**과 **Partial** 외에 실무에서 자주 쓰이는 핵심 유틸리티 타입들을 정리해 드립니다. + +--- + +## Omit Type + +`Omit`는 `Pick`의 정반대 개념으로, **"특정 속성만 제거하고 나머지(부분 집합)를 가져오는 도구"**입니다. + +보통 인터페이스가 매우 거대한데, 거기서 딱 한두 개만 빼고 싶을 때 `Pick`으로 나머지를 다 나열하는 것보다 `Omit`으로 제거하는 게 훨씬 효율적입니다. + +### 1. 코드 상세 해석 + +```ts +type Omit = Pick>; +``` + +`Omit`은 독자적인 맵드 타입이라기보다, `Pick`과 `Exclude`의 조합으로 이루어져 있습니다. + +1. **`Exclude`**: `T`의 키 목록(`keyof T`) 중에서 제거하고 싶은 키 `K`를 뺍니다(제외). +2. **`Pick<...>`**: 위에서 남은 키들만 가지고 다시 `Pick`을 수행하여 새로운 타입을 만듭니다. + +### 2. 실제 동작 예시 + +비밀번호 같은 민감한 정보를 클라이언트로 보낼 때 유용합니다. + +```ts +interface User { + id: number; + username: string; + email: string; + passwordHash: string; // 절대 클라이언트에 보내면 안 되는 정보 +} + +// 비밀번호만 쏙 뺀 타입을 생성 +type UserWithoutPassword = Omit; + +const validUser: UserWithoutPassword = { + id: 1, + username: "user123", + email: "test@test.com", + // passwordHash: "..." // ❌ 에러 발생! 이 속성은 존재하지 않습니다. +}; +``` + +## Required Type + +`Required`는 `Partial`의 정반대 역할을 합니다. **"모든 선택적(Optional) 속성을 필수(Required) 속성으로 바꿔주는 도구"**입니다. + +### 1. 코드 상세 해석 + +```ts +type Required = { + [P in keyof T]-?: T[P]; +}; +``` + +1. **`[P in keyof T]`**: `T`의 모든 키를 순회합니다. +2. **`-?`**: **핵심입니다.** `?`(Optional) 수식어를 **제거(-)**한다는 뜻입니다. 원래 `name?: string`이었다면 `name: string`으로 강제 변환됩니다. + +### 2. 실제 동작 예시 + +설정 객체 등에서 사용자가 일부만 입력해도 되지만(Optional), 내부 로직에서는 반드시 기본값이 채워진 상태(Required)로 다뤄야 할 때 씁니다. + +```ts +interface Config { + host?: string; + port?: number; +} + +// 사용자는 일부만 입력 가능 +const userConfig: Config = { host: "localhost" }; + +// 하지만 내부 로직에서는 모든 값이 채워져 있어야 함 +const completeConfig: Required = { + host: userConfig.host || "localhost", + port: userConfig.port || 8080 +}; + +// ✅ 안전함: port가 무조건 number임이 보장됨 (undefined 아님) +console.log(completeConfig.port.toFixed(2)); +``` + +## Readonly Type + +`Readonly`는 **"모든 속성을 읽기 전용(불변)으로 만들어주는 도구"**입니다. 생성 후에는 값을 절대 수정할 수 없게 막아줍니다. + +### 1. 코드 상세 해석 + +```ts +type Readonly = { + readonly [P in keyof T]: T[P]; +}; +``` + +1. **`readonly`**: 맵드 타입 앞에 `readonly` 키워드를 붙여서, 생성된 모든 속성에 읽기 전용 속성을 부여합니다. + +### 2. 실제 동작 예시 + +Redux의 상태(State)나 설정 상수처럼, 데이터가 도중에 변경되면 안 되는 경우 필수적입니다. + +```ts +interface Todo { + title: string; +} + +const myTodo: Readonly = { + title: "Delete logs" +}; + +console.log(myTodo.title); // 읽기는 가능 + +// ❌ 에러 발생! +// Cannot assign to 'title' because it is a read-only property. +myTodo.title = "Update logs"; +``` + +## 타입 정의를 먼저하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. + +- 나쁜 예 + +```ts +// 1. 값(구현)을 먼저 작성함 +const INIT_OPTIONS = { + path: '/home', + useSsl: true, + // ⚠️ 실수! 개발자가 오타를 냈습니다 (timeout -> timeOut) + timeOut: 500 +}; + +// 2. 값으로부터 타입을 뽑아냄 (설계가 구현을 따라감) +type Options = typeof INIT_OPTIONS; + +// 결과: Options 타입은 이제 { path: string; useSsl: boolean; timeOut: number; }가 됩니다. +// 'timeOut'이라는 오타가 '공식적인 타입'으로 굳어져 버립니다. +``` + +- 좋은 예 + +```ts +// 1. 타입(설계도)을 먼저 정의함 +interface Options { + path: string; + useSsl: boolean; + timeout: number; // 정확한 이름을 미리 정함 +} + +// 2. 값이 그 타입에 맞는지 검사하면서 작성 (설계를 따름) +const INIT_OPTIONS: Options = { + path: '/home', + useSsl: true, + // 🚨 에러 발생! + // Object literal may only specify known properties, but 'timeOut' does not exist in type 'Options'. + timeOut: 500 +}; +``` \ No newline at end of file diff --git a/docs/week1/item15~18.md b/docs/week1/item15~18.md new file mode 100644 index 0000000..32aa441 --- /dev/null +++ b/docs/week1/item15~18.md @@ -0,0 +1,342 @@ +# 아이템 15: 동적 데이터에 인덱스 시그니처 사용하기 + +## 1. 인덱스 시그니처가 하는 일 + +인덱스 시그니처는 “이 객체는 **어떤 키**가 와도 되고, 그 값은 **이 타입**이야” 라고 말합니다. + +```ts +type Rocket = { + [property: string]: string; // ✅ 키는 string, 값은 string +}; + +const rocket: Rocket = { + name: "Falcon 9", + variant: "v1.0", + thrust: "4,940 kN", +}; +``` + +- `[property: string]`의 `property` 이름은 **의미 없는 자리표시자**(타입 체크에 영향 없음) +- 키 타입은 보통 `string`을 씁니다(이외에도 `number | symbol` 가능) +- 값 타입은 무엇이든 가능 + +--- + +## 2. 인덱스 시그니처의 대표 단점 4가지 + +### (1) 오타 키도 다 허용됨 + +```ts +type Rocket = { [k: string]: string }; + +const r: Rocket = { + Name: "Falcon 9", // ❌ 오타인데도 통과 (name이어야 했을 수도) +}; +``` + +### (2) “필수 키”를 강제할 수 없음 + +```ts +type Rocket = { [k: string]: string }; + +const r: Rocket = {}; // ❌ 아무 키도 없어도 통과 +``` + +### (3) 키마다 다른 값 타입을 표현하기 어려움 + +```ts +type Rocket = { [k: string]: string }; + +// thrust는 number여야 하는데 string으로 고정돼 버림 +``` + +### (4) 언어 서비스(자동완성/리네임/점프)가 약해짐 + +키가 “뭐든 가능”이면 IDE가 추천/추론을 잘 못 합니다. + +--- + +## 3. 결론: 키를 알고 있다면 인덱스 시그니처 말고 인터페이스 + +키가 정해져 있다면 가장 좋은 건 **명시적 필드**입니다. + +```ts +interface Rocket { + name: string; + variant: string; + thrust_kN: number; +} + +const falconHeavy: Rocket = { + name: "Falcon Heavy", + variant: "v1", + thrust_kN: 15_200, +}; +``` + +✅ interface 장점 + +- 필수 필드 강제 +- 오타 방지 +- 키별 타입 분리 가능 +- 자동완성/정의로 이동/이름 바꾸기 등 언어 서비스가 최대치로 동작 + +> **결론:** _키를 미리 알 수 없는 “진짜 동적 데이터”에서만_ 인덱스 시그니처를 쓰고, 가능하면 **더 정확한 타입(인터페이스/Record/매핑된 타입/Map)** 으로 모델링하자. + +
+
+ +# 아이템 16: number 인덱스 시그니처보다 Array, 튜플, ArrayLike를 사용하기 + +## 1. 자바스크립트 객체와 키의 문제 + +- 숫자를 키로 써도 → **문자열로 변환** +- 배열 역시 객체이기 때문에 문자열 키 접근이 가능 + +👉 이게 **혼란의 시작점** + +--- + +## 2. 타입스크립트는 그 혼란을 타입으로 정리하려고 `number` 인덱스를 따로 취급 + +- 배열 인덱싱을 number 로만 허용하는 타입 규칙 도입: + +```ts +interface Array { + [n: number]: T; +} +``` + +--- + +## 3. number 인덱스 시그니처의 문제점 + +### ❌ 문자열 인덱싱이 타입을 망가뜨림 + +```ts +const xs = [1, 2, 3]; + +const a = xs[0]; // number +const b = xs["1"]; // ❌ 암묵적 any +``` + +```ts +function get(array: T[], k: string): T { + return array[k]; // ❌ 인덱스가 number가 아님 +} +``` + +- 타입 시스템이 깨짐 +- 암묵적 `any` 발생 +- IDE 추론/자동완성 품질 저하 + +--- + +## 4. 최종 결론: number 인덱스 시그니처보다 Array, 튜플, ArrayLike를 사용하기 + +길이와 숫자 접근만 필요할 때: + +```ts +function checkedAccess(xs: ArrayLike, i: number): T { + if (i < xs.length) { + return xs[i]; + } + throw new Error("범위를 벗어남"); +} +``` + +```ts +const tupleLike: ArrayLike = { + 0: "A", + 1: "B", + length: 2, +}; // ✅ 정상 +``` + +
+
+ +# 아이템 17: 변경 관련된 오류 방지를 위해 `readonly` 사용하기 + +## 1. 문제 상황 (1) : 변경이 숨어 있는 함수 + +## 2. 문제 상황 (2) : 버그의 핵심: 배열을 파괴하는 함수 + +```ts +function arraySum(arr: number[]) { + let sum = 0, + num; + while ((num = arr.pop()) !== undefined) { + sum += num; + } + return sum; +} +``` + +- `pop()`은 배열을 **직접 수정** +- 계산이 끝나면 `arr`은 빈 배열 +- 호출자는 이를 전혀 알 수 없음 ❌ + +--- + +## 3. 해결책: `readonly`로 의도 명시하기 + +```ts +function arraySum(arr: readonly number[]) { + let sum = 0, + num; + while ((num = arr.pop()) !== undefined) { + // ❌ 컴파일 에러: readonly에는 pop이 없음 + sum += num; + } + return sum; +} +``` + +✅ 효과 + +- 배열 변경 시 **즉시 컴파일 에러** +- 함수의 의도가 명확해짐 (“읽기 전용”) + +--- + +## 4. 매개변수에 `readonly`를 쓰면 생기는 일 + +- 타입스크립트가 **함수 내부 변경을 검사** +- 호출자는 “이 함수는 인자를 안 바꾼다”는 보장을 얻음 +- readonly 배열도 인자로 전달 가능 + +👉 **인터페이스 계약(contract)** 이 명확해짐 + +--- + +## 🔍 readonly는 얕게(shallow) 동작 + +```ts +const dates: readonly Date[] = [new Date()]; + +dates.push(new Date()); // ❌ +dates[0].setFullYear(2037); // ✅ +``` + +--- + +## 5. `const` 와 `readonly` 의 차이점 + +### (1) const 는 '변수 재할당' 을 막고, readonly 는 '값(구조)의 변경'을 타입 레벨에서 막는다 + +```ts +function arraySum(arr: readonly number[]) { + let sum = 0; + for (const num of arr) { + sum += num; + } + return sum; +} yRange: [number, number]; + color: string; + + onClick: (x: number, y: number, index: number) => void; +} +``` + +> `const`는 변수 **재할당만 금지**하고, `readonly number[]`는 **타입 레벨에서 내부 변경(pop, push 등)을 금지**한다. + +--- + +### (2) const 와 readonly 차이점 핵심 + +> **const는 runtime, readonly는 type-level이다** + + +--- + +### 한 문장으로 끝내기 + +> **const는 변수 보호, readonly는 데이터 보호다.** + +
+
+ +# 아이템 18: 매핑된 타입을 사용하여 값을 동기화하기 + +## 1. 문제 배경: Scatter 차트 최적화 + +산점도(scatter plot) UI 컴포넌트의 props 예시: + +```ts +interface ScatterProps { + xs: number[]; + ys: number[]; + + xRange: [number, number]; + yRange: [number, number]; + color: string; + + onClick: (x: number, y: number, index: number) => void; +} +``` + +렌더링 최적화를 위해 +“**어떤 props가 바뀌었을 때 다시 그릴지**”를 판단해야 한다. + +--- + +## 2. 안 좋은 접근 1: 조건 나열 (fail-open) + +```ts +function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) { + return ( + oldProps.xs !== newProps.xs || + oldProps.ys !== newProps.ys || + oldProps.xRange !== newProps.xRange || + oldProps.yRange !== newProps.yRange || + oldProps.color !== newProps.color + ); +} +``` + +❌ 새 prop 추가 시 빠뜨려도 타입 에러가 나지 않음 + +--- + +## 3. 안 좋은 접근 2: 반복 + 예외 처리 (fail-close) + +```ts +function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) { + let k: keyof ScatterProps; + for (k in oldProps) { + if (oldProps[k] !== newProps[k]) { + if (k !== "onClick") return true; + } + } + return false; +} +``` + +❌ 비교 규칙이 코드 안에 흩어짐 +❌ 유지보수 어려움 + +--- + +## 4. 핵심 해법: 매핑된 타입으로 규칙 고정 + +```ts +const REQUIRES_UPDATE: { [K in keyof ScatterProps]: boolean } = { + xs: true, + ys: true, + xRange: true, + yRange: true, + color: true, + onClick: false, +}; +``` + +- `ScatterProps`의 키와 **100% 동기화** +- 키 추가/삭제 시 즉시 컴파일 에러 + +--- + +## 5. 핵심은 매핑된 타입과 객체를 사용하는 것: 왜 배열이 아니라 객체인가? + +- 모든 키가 고려됐는지 보장 불가 ❌ +- boolean 맵 + 매핑된 타입만이 완전한 해결책 diff --git a/docs/week2/item28-32.md b/docs/week2/item28-32.md new file mode 100644 index 0000000..b1176b3 --- /dev/null +++ b/docs/week2/item28-32.md @@ -0,0 +1,2682 @@ +# 아이템 28: 유효한 상태만 표현하는 타입을 지향하기 + +TypeScript로 상태 관리를 할 때 `isLoading`과 `error`를 함께 사용하면 분기 처리가 복잡해지고 버그가 발생하기 쉽다. "로딩 중인데 에러도 있는" 상태가 가능하다 보니 코드가 뒤죽박죽이 될 수 있다. 이번 글에서는 **유효한 상태만 표현하는 타입 설계**에 대해 상세히 설명하겠다. + +## 타입 설계의 중요성 + +타입을 잘 설계하면 코드는 직관적으로 작성할 수 있다. 하지만 타입 설계가 엉망이면 어떤 문서나 주석도 도움이 안 된다. 버그가 창궐하게 된다. + +핵심 원칙: **유효한 상태만 표현할 수 있는 타입을 만들어야 한다.** + +## ⚠️ 문제 상황: 무효한 상태를 허용하는 타입 + +웹 애플리케이션에서 페이지를 로드하는 상황을 살펴보자. 처음에는 다음처럼 설계했다. + +```typescript +// ❌ 잘못된 설계: 무효한 상태를 허용함 +interface State { + pageText: string; + isLoading: boolean; // 로딩 중 + error?: string; // 에러 메시지 +} +``` + +이 설계의 문제점은 **`isLoading`이 `true`이면서 동시에 `error`가 존재할 수 있다**는 것이다. 이런 상태는 무효하다. 로딩 중인지, 에러가 발생한 건지 명확하지 않다. + +### 무효한 상태의 구체적 예시 + +이 타입으로 표현 가능한 모든 상태 조합을 살펴보자: + +| isLoading | error | pageText | 의미 | 유효성 | +|-----------|-------|----------|------|--------| +| false | undefined | "content" | 정상 로드 완료 | ✅ 유효 | +| true | undefined | "" | 로딩 중 | ✅ 유효 | +| false | "Network error" | "" | 에러 발생 | ✅ 유효 | +| **true** | **"Network error"** | "" | **로딩 중인데 에러도 있음?** | ❌ 무효 | +| **true** | **"Old error"** | **"Old content"** | **로딩 중인데 에러도 있고 콘텐츠도 있음?** | ❌ 무효 | +| **false** | **undefined** | **""** | **로딩도 아니고 에러도 아닌데 내용 없음?** | ❌ 무효 | + +무효한 상태가 타입 시스템에서 허용되면, 이런 상태가 실제로 발생했을 때 어떻게 처리해야 할지 명확하지 않다. `renderPage` 함수의 if-else 순서에 따라 결과가 달라지는데, 이는 타입 시스템이 보장하지 못한다. + +### 렌더링 함수의 모호함 + +```typescript +function renderPage(state: State) { + if (state.error) { + // 에러가 있으면 에러 표시 + return `Error! Unable to load ${currentPage}: ${state.error}`; + } else if (state.isLoading) { + // 로딩 중이면 로딩 표시 + return `Loading ${currentPage}...`; + } + // 정상이면 페이지 표시 + return `

${currentPage}

\n${state.pageText}`; +} +``` + +문제: `isLoading`이 `true`이고 `error`도 존재하면? 첫 번째 분기에서 에러를 표시하게 되는데, 이게 의도한 동작인지 명확하지 않다. + +### 페이지 전환 함수의 버그들 + +```typescript +// ❌ 여러 버그를 가진 함수 +async function changePage(state: State, newPage: string) { + state.isLoading = true; + try { + const response = await fetch(getUrlForPage(newPage)); + if (!response.ok) { + throw new Error(`Unable to load ${newPage}: ${response.statusText}`); + } + const text = await response.text(); + state.isLoading = false; + state.pageText = text; + } catch (e) { + state.error = '' + e; + // ★ 버그 1: isLoading을 false로 설정하지 않음 (무의미한 변수) + // ★ 버그 2: 이전 error를 초기화하지 않아서 과거 에러가 남아있을 수 있음 + // ★ 버그 3: 페이지 전환 중 사용자가 또 페이지를 바꾸면 경합 조건 발생 + } +} +``` + +### 버그 2 상세 분석: 이전 error가 남아있는 문제 + +위 코드를 자세히 보면, **성공 경로(try 블록)에서 `state.error`를 초기화하지 않는다**는 걸 알 수 있다. + +- 71줄: `state.isLoading = false` ✅ 업데이트됨 +- 72줄: `state.pageText = text` ✅ 업데이트됨 +- `state.error`는? ❌ 건드리지 않음! + +**타임라인 시나리오:** + +```typescript +// 초기 상태 +const state: State = { pageText: "", isLoading: false, error: undefined }; + +// 1️⃣ 페이지 A 로드 시도 → 실패 +await changePage(state, 'A'); +// catch 블록 실행: +// state.error = "Unable to load A: 404" +// state.isLoading은 true로 남음 (버그 1) +// 결과: { pageText: "", isLoading: true, error: "Unable to load A: 404" } + +// 2️⃣ 페이지 B 로드 시도 → 성공! +await changePage(state, 'B'); +// try 블록 성공: +// state.isLoading = false +// state.pageText = "Content of B" +// state.error는 그대로! ← 문제! +// 결과: { pageText: "Content of B", isLoading: false, error: "Unable to load A: 404" } + +// 3️⃣ 렌더링 +renderPage(state); +// if (state.error) 체크 → true! +// 결과: "Error! Unable to load A: 404" 표시 +// 💥 페이지 B는 성공했는데 과거 A의 에러가 보임! +``` + +### 버그 3 상세 분석: 경합 조건 (Race Condition) + +"React는 싱글스레드인데 경합 조건이 어떻게 발생하나?"라고 생각할 수 있다. 하지만 **싱글스레드와 경합 조건은 별개**다. JavaScript는 싱글스레드지만, **비동기 작업의 완료 순서는 시작 순서와 다를 수 있다**. + +**구체적 시나리오:** + +```typescript +// t=0ms: 사용자가 페이지 A 클릭 +changePage(state, 'A'); // fetch('A') 시작, 네트워크로 요청 날아감 + +// t=100ms: 사용자가 빠르게 페이지 B 클릭 (A가 아직 완료 안 됨!) +changePage(state, 'B'); // fetch('B') 시작, 또 다른 요청 날아감 + +// 이제 네트워크에 두 개의 요청이 동시에 날아가 있음 +// 어느 게 먼저 돌아올까? 네트워크 속도에 따라 다름! + +// 💥 시나리오 1: B가 먼저 도착 (나쁨) +// t=150ms: fetch('B') 완료 → state.pageText = "Content of B" +// t=300ms: fetch('A') 완료 → state.pageText = "Content of A" // B를 덮어씀! +// 결과: 사용자는 B를 보려고 했는데 A가 표시됨 + +// ✅ 시나리오 2: A가 먼저 도착 (운 좋게 정상) +// t=200ms: fetch('A') 완료 → state.pageText = "Content of A" +// t=250ms: fetch('B') 완료 → state.pageText = "Content of B" +// 결과: 정상적으로 B가 표시됨 +``` + +**왜 이런 일이 발생하나?** + +JavaScript는 싱글스레드지만: +1. `fetch('A')`가 시작되면 백그라운드(브라우저 네트워크 스택)로 넘어감 +2. `fetch('B')`도 백그라운드로 시작됨 +3. 둘 중 **먼저 완료된 것의 콜백이 먼저 실행됨** ← 순서 보장 안 됨! + +네트워크 속도, 서버 응답 시간에 따라 결과가 달라진다. 이게 경합 조건이다. + +이런 버그들이 발생하는 근본 원인은 **타입 설계가 무효한 상태를 허용하기 때문**이다. + +## 🛠️ 해결 방법: Tagged Union 패턴 + +상태를 명시적으로 모델링하는 **태그된 유니온(Tagged Union)** 패턴을 사용하자. + +```typescript +// ✅ 올바른 설계: 각 상태를 명시적으로 표현 +interface RequestPending { + state: 'pending'; // 로딩 중 상태 +} + +interface RequestError { + state: 'error'; + error: string; // 에러 상태에만 error 존재 +} + +interface RequestSuccess { + state: 'ok'; + pageText: string; // 성공 상태에만 pageText 존재 +} + +// 요청 상태는 정확히 하나의 상태만 가능 +type RequestState = RequestPending | RequestError | RequestSuccess; + +interface State { + currentPage: string; + requests: { [page: string]: RequestState }; +} +``` + +### 개선된 렌더링 함수 + +```typescript +function renderPage(state: State) { + const { currentPage } = state; + const requestState = state.requests[currentPage]; + + // switch 문으로 명확하게 분기 처리 + switch (requestState.state) { + case 'pending': + return `Loading ${currentPage}...`; + case 'error': + return `Error! Unable to load ${currentPage}: ${requestState.error}`; + case 'ok': + return `

${currentPage}

\n${requestState.pageText}`; + } + // ★ TypeScript가 모든 경우를 처리했는지 컴파일 타임에 검증해줌 +} +``` + +### 개선된 페이지 전환 함수 + +```typescript +async function changePage(state: State, newPage: string) { + // 로딩 상태로 명시적 설정 + state.requests[newPage] = { state: 'pending' }; + state.currentPage = newPage; + + try { + const response = await fetch(getUrlForPage(newPage)); + if (!response.ok) { + throw new Error(`Unable to load ${newPage}: ${response.statusText}`); + } + const pageText = await response.text(); + + // 성공 상태로 설정 + state.requests[newPage] = { state: 'ok', pageText }; + } catch (e) { + // 에러 상태로 설정 + state.requests[newPage] = { state: 'error', error: '' + e }; + // ★ 각 상태가 완전히 독립적이므로 isLoading 미설정 같은 버그 불가능 + } +} +``` + +### 다른 구현 방식: Base Interface 상속 + +Tagged Union을 구현할 때 공통 속성을 base interface로 추출하는 방식도 있다. + +```typescript +// Base interface로 공통 구조 정의 +interface BaseRequestState { + state: string; +} + +// 각 상태가 base를 상속 +interface RequestPending extends BaseRequestState { + state: 'pending'; +} + +interface RequestError extends BaseRequestState { + state: 'error'; + error: string; +} + +interface RequestSuccess extends BaseRequestState { + state: 'ok'; + pageText: string; +} + +type RequestState = RequestPending | RequestError | RequestSuccess; +``` + +사용 방법은 일반 Tagged Union과 동일하다. + +```typescript +function renderPage(state: State) { + const requestState = state.requests[state.currentPage]; + + // 똑같이 switch로 분기 처리 + switch (requestState.state) { + case 'pending': + return `Loading...`; + case 'error': + return `Error: ${requestState.error}`; + case 'ok': + return requestState.pageText; + } +} +``` + +### 두 방식의 비교 + +| 항목 | 일반 Tagged Union | Base Interface 상속 | +|------|-------------------|---------------------| +| **코드 길이** | 짧고 간결함 | extends 키워드로 약간 더 김 | +| **명시성** | 암묵적 구조 | 공통 구조를 명시적으로 표현 | +| **타입 안전성** | 동일 | 동일 | +| **보일러플레이트** | 최소 | 약간 더 많음 | +| **확장성** | 충분함 | 공통 메서드 추가에 유리 | +| **친숙함** | 함수형 프로그래밍 스타일 | OOP 배경 개발자에게 친숙 | + +### 어떤 방식을 선택할까? + +**대부분의 경우: 일반 Tagged Union 권장** + +```typescript +// ✅ 간결하고 명확한 일반 방식 +interface RequestPending { + state: 'pending'; +} +``` + +TypeScript의 discriminated union이 이미 완벽한 타입 안전성을 제공하므로, base interface를 명시적으로 만들 필요가 없다. + +**다음 경우에는 Base Interface 상속 고려** + +1. 모든 상태에 공통 메서드나 속성이 필요한 경우 + +```typescript +interface BaseRequestState { + state: string; + timestamp: Date; // 모든 상태가 timestamp를 가짐 + reset(): void; // 공통 메서드 +} +``` + +2. OOP 스타일 코드베이스에서 일관성을 유지하고 싶을 때 + +3. 상태 객체가 복잡한 로직을 가지고 class로 구현해야 할 때 + +```typescript +abstract class BaseRequestState { + abstract state: string; + + isComplete(): boolean { + return this.state === 'ok' || this.state === 'error'; + } +} + +class RequestPending extends BaseRequestState { + state = 'pending' as const; +} +``` + +하지만 대부분의 상황에서는 **YAGNI 원칙**에 따라 간단한 Tagged Union으로 시작하고, 정말 필요할 때만 base interface를 추가하는 게 좋다. + +### Class로 구현하면 어떨까? + +"내부 함수를 사용해서 깔끔하게 구현할 수 있지 않을까?" 상태를 class로 구현하는 방법도 가능하다: + +```typescript +abstract class RequestState { + abstract readonly state: string; + + abstract render(currentPage: string): string; +} + +class RequestPending extends RequestState { + readonly state = 'pending' as const; + + render(currentPage: string) { + return `Loading ${currentPage}...`; + } +} + +class RequestError extends RequestState { + readonly state = 'error' as const; + + constructor(public readonly error: string) { + super(); + } + + render(currentPage: string) { + return `Error! ${this.error}`; + } +} + +class RequestSuccess extends RequestState { + readonly state = 'ok' as const; + + constructor(public readonly pageText: string) { + super(); + } + + render(currentPage: string) { + return `

${currentPage}

\n${this.pageText}`; + } +} + +type RequestStateUnion = RequestPending | RequestError | RequestSuccess; + +// 사용 +function renderPage(state: State) { + const requestState = state.requests[state.currentPage]; + return requestState.render(state.currentPage); // 각 상태가 자신의 렌더링 로직 가짐 +} +``` + +**장점:** +- render 로직이 각 상태 클래스 안에 캡슐화됨 +- OOP 패턴에 익숙한 개발자에게 친숙함 +- 새 상태 추가 시 추상 메서드 구현을 강제함 + +**하지만 왜 Class + Interface보다 Type + Tagged Union이 나은가?** + +TypeScript에서 상태를 모델링하는 두 가지 접근 방식이 있다: + +1. **Class + Interface**: 각 상태를 class로 구현하고 interface로 공통 타입 정의 +2. **Type + Tagged Union**: 각 상태를 type alias로 정의하고 union으로 결합 (Discriminated Union) + +#### 1. 타입 정의 방식 + +```typescript +// ❌ Class + Interface 방식 +interface RequestState { + state: 'pending' | 'error' | 'ok'; +} + +class RequestPending implements RequestState { + state: 'pending' = 'pending'; +} + +class RequestError implements RequestState { + state: 'error' = 'error'; + constructor(public error: string) {} +} + +class RequestSuccess implements RequestState { + state: 'ok' = 'ok'; + constructor(public pageText: string) {} +} + +// ✅ Type + Tagged Union 방식 (권장) +type RequestPending = { state: 'pending' }; +type RequestError = { state: 'error', error: string }; +type RequestSuccess = { state: 'ok', pageText: string }; +type RequestState = RequestPending | RequestError | RequestSuccess; +``` + +Type 방식이 훨씬 간결하고 선언적이다. Class 방식은 보일러플레이트 코드가 많다. + +#### 2. 타입 좁히기 (Type Narrowing) + +**표면적으로는 비슷해 보인다:** + +```typescript +// Type + Tagged Union +function handleRequest(state: RequestState) { + if (state.state === 'ok') { + console.log(state.pageText); + } +} + +// Class + instanceof +function handleRequest(state: RequestState) { + if (state instanceof RequestSuccess) { + console.log(state.pageText); + } +} +``` + +둘 다 if문으로 타입을 좁히는 것처럼 보인다. **하지만 실제 웹 개발에서는 결정적인 차이가 있다.** + +**🚨 실제 문제: API 응답 처리** + +```typescript +// 실제 웹 애플리케이션에서 흔한 시나리오 +async function loadPage(url: string) { + const response = await fetch(url); + const state: RequestState = await response.json(); + // ↑ JSON.parse는 plain object를 반환: { state: 'ok', pageText: '...' } + + // ✅ Tagged Union: 완벽하게 작동 + if (state.state === 'ok') { + return state.pageText; // 타입 안전하게 접근 가능 + } + + // ❌ Class + instanceof: 완전히 망가짐 + if (state instanceof RequestSuccess) { // ← 항상 false! + // 절대 도달 불가! + // JSON.parse는 plain object를 반환하므로 + // RequestSuccess class의 instance가 아님 + return state.pageText; + } + + // Class 방식을 사용하려면 수동 변환 필요 + const stateObj = state as any; // 😱 타입 안전성 포기 + if (stateObj.state === 'ok') { + return new RequestSuccess(stateObj.pageText); // 매번 new 필요 + } +} +``` + +**왜 이런 차이가 생기나?** + +| 방식 | 검사 방법 | 필요 조건 | JSON 호환성 | +|------|-----------|-----------|-------------| +| **Tagged Union** | 값 기반 (`state.state === 'ok'`) | 타입 정보만 필요 (컴파일 후 사라짐) | ✅ 완벽 (plain object) | +| **instanceof** | 참조 기반 (prototype chain 검사) | 런타임에 class가 존재해야 함 | ❌ 불가능 (class instance 필요) | + +**실용적 영향:** + +```typescript +// 1. Redux 상태 관리 +const state = JSON.parse(localStorage.getItem('state')); +if (state instanceof RequestSuccess) { // ❌ false! + // Redux는 plain object만 허용 +} + +// 2. API 테스트 +const mockResponse: RequestState = { state: 'ok', pageText: 'test' }; +if (mockResponse instanceof RequestSuccess) { // ❌ false! + // 테스트 데이터를 만들 때마다 new 필요 +} + +// 3. 타입 가드 재사용 +function isSuccess(state: RequestState): state is RequestSuccess { + return state.state === 'ok'; // ✅ 값만 검사, 어디서든 작동 +} + +function isSuccess(state: RequestState): state is RequestSuccess { + return state instanceof RequestSuccess; // ❌ JSON 데이터에서는 false +} +``` + +**결론:** +- 현대 웹 개발은 JSON 기반 (API, localStorage, Redux 등) +- Tagged Union은 값 기반 검사로 JSON과 완벽히 호환 +- instanceof는 class instance가 필요해서 JSON 데이터에서 작동 안 함 +- 이것이 TypeScript에서 Tagged Union을 선호하는 결정적 이유 + +#### 3. 패턴 매칭과 Exhaustiveness Checking + +```typescript +// ✅ Type + Tagged Union: switch문에서 모든 케이스 검증 +function render(state: RequestState): string { + switch (state.state) { + case 'pending': + return 'Loading...'; + case 'error': + return `Error: ${state.error}`; // state.error 자동으로 접근 가능 + case 'ok': + return state.pageText; // state.pageText 자동으로 접근 가능 + } + // 새로운 상태 추가 시 여기서 컴파일 에러 발생 → 누락 방지 +} +``` + +TypeScript는 switch문에서 모든 union 멤버를 처리했는지 검증한다. 새로운 상태를 추가하면 컴파일 에러로 알려준다. + +**Q: Tagged union의 각 variant가 특정 메서드를 가지도록 강제할 수 있나?** + +메서드를 직접 포함시킬 수 없다 (JSON 직렬화 시 손실). 대신 **함수로 분리**한다. + +```typescript +// ❌ 메서드를 포함시키면 JSON 직렬화 시 손실 +type RequestPending = { state: 'pending'; render: () => string }; + +// ✅ 방법 1: 함수형 접근 (권장) +function render(state: RequestState): string { + switch (state.state) { + case 'pending': return 'Loading...'; + case 'error': return `Error: ${state.error}`; + case 'ok': return state.pageText; + } + // 모든 케이스 처리 강제 (exhaustiveness checking) +} + +// ✅ 방법 2: Pattern matching 함수 (복잡한 경우) +function match( + state: RequestState, + handlers: { + pending: () => R; + error: (err: string) => R; + ok: (text: string) => R; + } +): R { + switch (state.state) { + case 'pending': return handlers.pending(); + case 'error': return handlers.error(state.error); + case 'ok': return handlers.ok(state.pageText); + } +} + +// 사용: 모든 케이스 처리 강제됨 +const html = match(state, { + pending: () => '
Loading
', + error: (err) => `
${err}
`, + ok: (text) => `
${text}
` + // 하나라도 누락하면 컴파일 에러! +}); +``` + +이것이 TypeScript의 **"데이터와 로직 분리"** 철학이다. Class의 캡슐화를 포기하는 대신, JSON 호환성과 exhaustiveness checking을 얻는다. + +#### 4. 불변성과 직렬화 + +```typescript +// ✅ Type + Tagged Union: Plain object 사용 +const state1: RequestState = { state: 'pending' }; +const state2: RequestState = { state: 'ok', pageText: 'content' }; + +// 스프레드 연산자로 쉽게 복사/수정 +const updated = { ...state2, pageText: 'new content' }; + +// JSON 직렬화가 자연스럽게 동작 +const saved = JSON.stringify(state2); +const loaded: RequestState = JSON.parse(saved); // ✅ 바로 사용 가능 + + +// ❌ Class: 인스턴스 생성과 직렬화 문제 +const state1 = new RequestPending(); +const state2 = new RequestSuccess('content'); + +// 매번 new 필요 +const state3 = new RequestSuccess('content'); + +// JSON 직렬화 시 메서드 손실 +const saved = JSON.stringify(state2); +// → {"state":"ok","pageText":"content"} ← class의 메서드 사라짐! +const loaded = JSON.parse(saved); // ❌ prototype chain 손실 +``` + +**왜 중요한가?** +- React/Redux: 불변 상태 관리에서 plain object가 필수 +- API 통신: JSON 직렬화/역직렬화가 빈번 +- localStorage: 직렬화된 데이터 저장 +- Class는 이런 상황에서 메서드를 잃고, prototype chain 복원이 필요 + +#### 5. 테스트 용이성 + +```typescript +// ✅ Type + Tagged Union: 객체 리터럴로 간단히 생성 +const mockState: RequestState = { state: 'ok', pageText: 'test data' }; + +// ❌ Class: 생성자 호출 필요 +const mockState: RequestState = new RequestSuccess('test data'); +``` + +테스트에서 mock 데이터를 만들 때 plain object가 훨씬 간단하다. + +#### 언제 Class를 사용하나? + +**복잡한 비즈니스 로직이나 생명주기 관리가 필요할 때:** + +```typescript +// ✅ Class가 적합한 경우: 복잡한 로직 + 내부 상태 +class HttpClient { + private retryCount = 0; + private abortController = new AbortController(); + + async fetch(url: string) { + // 복잡한 재시도 로직, 타임아웃 처리, 에러 핸들링 + while (this.retryCount < 3) { + try { + return await fetch(url, { signal: this.abortController.signal }); + } catch (error) { + this.retryCount++; + } + } + } + + abort() { + this.abortController.abort(); + } +} +``` + +**결론:** + +- **상태 모델링**: Type + Tagged Union이 적합 (간결, 타입 안전, 불변성, 직렬화) +- **복잡한 로직/서비스**: Class가 적합 (캡슐화, 생명주기 관리) + +### Tagged Union의 장점 + +| 항목 | 잘못된 설계 | Tagged Union | +|------|-------------|--------------| +| **무효한 상태** | 허용 (loading + error 동시 가능) | 불가능 (하나의 상태만 존재) | +| **타입 안정성** | 런타임 에러 가능 | 컴파일 타임에 검증 | +| **코드 명확성** | 모호한 분기 처리 | switch로 명확한 분기 | +| **버그 가능성** | 높음 (상태 초기화 실수 등) | 낮음 (상태가 완전히 교체됨) | + +# Item 29: 사용할 때는 너그럽게, 생성할 때는 엄격하게 + +## 포스텔의 법칙 + +TCP를 만든 존 포스텔이 남긴 견고성 원칙(Robustness Principle)이 있다. + +> 당신의 작업은 엄격하게 하고, 다른 사람의 작업은 너그럽게 받아들여야 한다. + +TypeScript 함수 설계에도 똑같이 적용된다. **매개변수는 너그럽게, 반환 타입은 엄격하게.** + +## 문제 상황 + +3D 매핑 API를 예로 들어보자. + +```typescript +// 초기 설계 +interface CameraOptions { + center?: LngLat; + zoom?: number; + bearing?: number; + pitch?: number; +} + +type LngLat = + | { lng: number; lat: number; } + | { lon: number; lat: number; } + | [number, number]; + +type LngLatBounds = + | { northeast: LngLat, southwest: LngLat } + | [LngLat, LngLat] + | [number, number, number, number]; + +declare function setCamera(camera: CameraOptions): void; +declare function viewportForBounds(bounds: LngLatBounds): CameraOptions; +``` + +매개변수 타입을 유연하게 만들어서 사용하기 편하게 했다. `LngLat`는 3가지 형태, `LngLatBounds`는 무려 19가지 이상의 형태를 받을 수 있다. + +### ⚠️ 반환 타입도 너무 자유로우면? + +```typescript +function focusOnFeature(f: Feature) { + const bounds = calculateBoundingBox(f); + const camera = viewportForBounds(bounds); + setCamera(camera); + + const { center: { lat, lng }, zoom } = camera; + // ❌ 형식에 'lat' 속성이 없습니다 + // ❌ 형식에 'lng' 속성이 없습니다 + // ❌ zoom 타입이 number | undefined + + window.location.search = `?v=@${lat},${lng}z${zoom}`; +} +``` + +`viewportForBounds`가 반환하는 `CameraOptions`는 모든 필드가 선택적이고, `center`도 3가지 형태 중 하나다. 이걸 사용하려면 매번 타입 좁히기를 해야 한다. + +**매개변수 타입의 범위가 넓으면 사용하기 편하지만, 반환 타입의 범위가 넓으면 불편하다.** + +## 🛠️ 해결: 기본 형태와 느슨한 형태 분리 + +```typescript +// 엄격한 기본 형태 (반환용) +interface LngLat { + lng: number; + lat: number; +} + +interface Camera { + center: LngLat; + zoom: number; + bearing: number; + pitch: number; +} + +// 느슨한 형태 (매개변수용) +type LngLatLike = + | LngLat + | { lon: number; lat: number; } + | [number, number]; + +interface CameraOptions extends Omit, 'center'> { + center?: LngLatLike; +} + +type LngLatBounds = + | { northeast: LngLatLike, southwest: LngLatLike } + | [LngLatLike, LngLatLike] + | [number, number, number, number]; + +// 매개변수는 너그럽게, 반환은 엄격하게 +declare function setCamera(camera: CameraOptions): void; +declare function viewportForBounds(bounds: LngLatBounds): Camera; +``` + +### ✅ 이제 타입 안전하게 사용 가능 + +```typescript +function focusOnFeature(f: Feature) { + const bounds = calculateBoundingBox(f); + const camera = viewportForBounds(bounds); // Camera 타입 반환 + setCamera(camera); + + const { center: { lat, lng }, zoom } = camera; // ✅ 타입 안전 + // zoom 타입이 number + window.location.search = `?v=@${lat},${lng}z${zoom}`; +} +``` + +`Camera`는 모든 필드가 필수이고 `center`도 정확히 `LngLat` 형태다. 반환값을 바로 사용할 수 있다. + +## 왜 이렇게 나눠야 하나? + +| 구분 | 매개변수 | 반환 타입 | +|------|----------|-----------| +| 목표 | 사용하기 편하게 | 사용하기 안전하게 | +| 타입 범위 | 넓게 (선택적, 유니온) | 좁게 (필수, 구체적) | +| 예시 | `CameraOptions`, `LngLatLike` | `Camera`, `LngLat` | + +**라이브러리를 만든다면:** +- 매개변수: 다양한 형태를 받아서 사용자 편의성 ↑ +- 반환 타입: 명확한 형태로 반환해서 타입 안정성 ↑ + +**하지만 19가지 형태를 받는 건 좋은 설계가 아니다.** 라이브러리가 어쩔 수 없이 호환성을 위해 그래야 한다면 모를까, 처음부터 이렇게 만들지는 말자. + +## 마치며 + +포스텔의 법칙을 TypeScript에 적용하면: +- **매개변수**: 너그럽게 받아라 (`LngLatLike`, `CameraOptions`) +- **반환 타입**: 엄격하게 돌려줘라 (`LngLat`, `Camera`) + +이렇게 하면 API 사용자는 편하게 호출하고, 안전하게 결과를 쓸 수 있다. + + +--- + +## 반환 타입이 복잡한 케이스에선 어떻게 핸들링해야 할까? + +실무에서 흔히 마주하는 상황을 투표 시스템으로 살펴보자. 포스텔의 법칙을 제대로 적용하지 않으면 어떤 문제가 생기는지 알아보겠다. + +### ⚠️ 나쁜 설계: 반환 타입도 느슨함 + +```typescript +// 입력과 출력이 같은 타입 (❌) +type VoteData = { + id?: string; + title: string; + choices: string[] | Set; // 유니온 타입 + endDate?: Date | string | number; // 유니온 타입 + allowMultiple?: boolean; // optional + status?: 'active' | 'closed'; // optional +} + +function createVote(data: VoteData): VoteData { + const id = generateId(); + return { ...data, id, status: 'active' }; +} + +function getVote(id: string): VoteData { + // DB에서 조회 + return db.votes.findById(id); +} +``` + +이렇게 설계하면 사용하는 쪽에서 매번 타입 체크가 필요하다. + +```typescript +const vote = getVote('v1'); + +// choices가 배열인지 Set인지 모름 +if (Array.isArray(vote.choices)) { + console.log(`선택지 ${vote.choices.length}개`); +} else { + console.log(`선택지 ${vote.choices.size}개`); +} + +// endDate의 형태를 모름 +if (vote.endDate) { + if (vote.endDate instanceof Date) { + console.log(vote.endDate.getTime()); + } else if (typeof vote.endDate === 'string') { + console.log(new Date(vote.endDate).getTime()); + } else { + console.log(vote.endDate); // timestamp + } +} + +// status가 있는지 확인 필요 +if (vote.status === 'active') { + // allowMultiple도 확인 필요 + if (vote.allowMultiple) { + // ... + } +} +``` + +**반환 타입이 느슨하면 사용하는 쪽에서 매번 타입 좁히기를 해야 한다.** + +### 🛠️ 좋은 설계: 매개변수는 느슨하게, 반환은 엄격하게 + +```typescript +// 입력용 타입: 다양한 형태 허용 +type CreateVoteInput = { + title: string; + choices: string[] | Set; // ✅ 배열이든 Set이든 OK + endDate?: Date | string | number; // ✅ Date, ISO string, timestamp 모두 OK + allowMultiple?: boolean; // ✅ 생략 가능 +} + +// 출력용 타입: 엄격하고 명확 +type Vote = { + id: string; // ✅ 필수 + title: string; // ✅ 필수 + choices: readonly string[]; // ✅ 항상 배열 + endDate: Date; // ✅ 항상 Date 객체 + status: 'active' | 'closed'; // ✅ 필수 + allowMultiple: boolean; // ✅ 필수 (기본값 false) +} + +function createVote(input: CreateVoteInput): Vote { + // 입력을 정규화해서 엄격한 타입으로 변환 + const choices = Array.isArray(input.choices) + ? input.choices + : Array.from(input.choices); + + const endDate = input.endDate instanceof Date + ? input.endDate + : input.endDate + ? new Date(input.endDate) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 기본 7일 + + return { + id: generateId(), + title: input.title, + choices, + endDate, + status: 'active', + allowMultiple: input.allowMultiple ?? false, + }; +} + +function getVote(id: string): Vote { + const raw = db.votes.findById(id); + + // DB 데이터를 엄격한 타입으로 변환 + return { + id: raw.id, + title: raw.title, + choices: Array.isArray(raw.choices) ? raw.choices : Array.from(raw.choices), + endDate: raw.endDate instanceof Date ? raw.endDate : new Date(raw.endDate), + status: raw.status, + allowMultiple: raw.allowMultiple ?? false, + }; +} +``` + +### ✅ 사용하는 쪽: 타입 체크 불필요 + +```typescript +// 생성 시: 다양한 형태로 줄 수 있음 +const vote1 = createVote({ + title: '점심 메뉴', + choices: ['김치찌개', '된장찌개', '순두부'], // 배열 + endDate: '2024-12-31', // ISO string +}); + +const vote2 = createVote({ + title: '저녁 메뉴', + choices: new Set(['피자', '치킨']), // Set + endDate: Date.now() + 86400000, // timestamp + allowMultiple: true, +}); + +// 조회 시: 항상 일관된 형태 +const vote = getVote('v1'); + +// 타입 체크 없이 바로 사용 +console.log(`선택지 ${vote.choices.length}개`); // 항상 배열 +console.log(vote.endDate.getTime()); // 항상 Date +console.log(vote.allowMultiple ? '복수 선택' : '단일 선택'); // 항상 boolean + +if (vote.status === 'active') { + // 모든 필드가 확실히 존재 + vote.choices.forEach(choice => console.log(choice)); +} +``` + +**반환 타입이 엄격하면 사용하는 쪽에서 안전하게 쓸 수 있다.** + +## 실전 패턴: 외부 API Adapter + +외부 라이브러리나 레거시 API를 사용할 때도 같은 원칙을 적용할 수 있다. + +### ⚠️ 문제: 외부 API의 느슨한 타입을 그대로 노출 + +```typescript +// 외부 라이브러리 (타입이 엉망) +declare module 'legacy-vote-lib' { + export function fetchVote(id: string): { + id?: string; + title?: string; + options?: any; + deadline?: string | number | Date; + status?: string; + } +} + +// 우리 코드에서 그대로 사용 (❌) +import { fetchVote } from 'legacy-vote-lib'; + +export function getVote(id: string) { + return fetchVote(id); // 느슨한 타입이 그대로 노출됨 +} + +// 사용하는 쪽 +const vote = getVote('v1'); +if (vote.id && vote.title && vote.options) { // 매번 체크 + if (Array.isArray(vote.options)) { + // ... + } +} +``` + +### 🛠️ 해결: Adapter로 감싸서 엄격한 타입 반환 + +```typescript +import { fetchVote } from 'legacy-vote-lib'; + +// 우리가 보장하는 엄격한 타입 +type Vote = { + id: string; + title: string; + choices: string[]; + endDate: Date; + status: 'active' | 'closed'; +} + +// Adapter: 외부 데이터를 우리 타입으로 변환 +export function getVote(id: string): Vote { + const raw = fetchVote(id); + + // 검증 + 변환 + if (!raw.id || !raw.title || !raw.options) { + throw new Error(`Invalid vote data: ${id}`); + } + + return { + id: raw.id, + title: raw.title, + choices: Array.isArray(raw.options) + ? raw.options + : String(raw.options).split(','), + endDate: raw.deadline instanceof Date + ? raw.deadline + : new Date(raw.deadline!), + status: raw.status === 'active' ? 'active' : 'closed', + }; +} + +// 사용하는 쪽: 타입 안전 +const vote = getVote('v1'); +console.log(vote.choices.length); // ✅ 항상 배열 +console.log(vote.endDate.getTime()); // ✅ 항상 Date +``` + +**Adapter 패턴의 핵심:** +- 외부 세계는 느슨한 타입 (어쩔 수 없음) +- 우리 코드는 엄격한 타입 (우리가 보장) +- 경계에서 변환 (한 곳에서 처리) + +## 체크리스트: 내 API가 포스텔의 법칙을 따르는가? + +### ✅ 매개변수 (너그럽게) +- [ ] 여러 형태를 받을 수 있나? (배열/Set, Date/string/number) +- [ ] 선택적 필드가 적절한가? (기본값 제공) +- [ ] 사용자가 편하게 호출할 수 있나? + +### ✅ 반환 타입 (엄격하게) +- [ ] 모든 필드가 명확한 타입인가? (유니온 최소화) +- [ ] 필수 필드는 optional이 아닌가? +- [ ] 사용하는 쪽에서 타입 체크 없이 쓸 수 있나? + +### ❌ 안티패턴 +- [ ] 입력과 출력이 같은 타입인가? → 분리하기 +- [ ] 반환 타입에 과도한 optional인가? → 필수로 만들기 +- [ ] 반환 타입에 불필요한 유니온인가? → 하나로 정규화하기 + +## 실전 팁 + +### Tip 1: Input/Output 타입 분리 + +```typescript +// ❌ 나쁨: 하나의 타입으로 입출력 겸용 +interface VoteData { /* ... */ } +function createVote(data: VoteData): VoteData; + +// ✅ 좋음: Input과 Output 명확히 분리 +type CreateVoteInput = { /* 느슨 */ }; +type Vote = { /* 엄격 */ }; +function createVote(input: CreateVoteInput): Vote; +``` + +### Tip 2: 변환 함수 분리 + +```typescript +// 정규화 로직을 별도 함수로 +function normalizeChoices(choices: string[] | Set): readonly string[] { + return Array.isArray(choices) ? choices : Array.from(choices); +} + +function normalizeDate(date: Date | string | number): Date { + return date instanceof Date ? date : new Date(date); +} + +function createVote(input: CreateVoteInput): Vote { + return { + // ... + choices: normalizeChoices(input.choices), + endDate: normalizeDate(input.endDate), + }; +} +``` + +### Tip 3: 타입 가드로 검증 + +```typescript +function isValidVote(vote: any): vote is Vote { + return ( + typeof vote.id === 'string' && + typeof vote.title === 'string' && + Array.isArray(vote.choices) && + vote.endDate instanceof Date && + (vote.status === 'active' || vote.status === 'closed') && + typeof vote.allowMultiple === 'boolean' + ); +} + +export function getVote(id: string): Vote { + const raw = db.votes.findById(id); + const normalized = normalizeVote(raw); + + if (!isValidVote(normalized)) { + throw new Error('Invalid vote data from DB'); + } + + return normalized; +} +``` + +## 고급 패턴: Branded Types와 Design Pattern으로 if-else 제거하기 + +앞선 예시들은 포스텔의 법칙을 잘 보여주지만, 실무에서는 또 다른 문제가 있다. **서비스 레이어에서 union type을 처리하기 위한 if-else가 여러 함수에 반복된다는 것이다.** + +### ⚠️ 문제: 타입 체크 로직의 중복 + +```typescript +// 여러 서비스 함수에서 동일한 타입 체크가 반복됨 +function createVote(input: CreateVoteInput): Vote { + // choices 타입 체크 + const choices = Array.isArray(input.choices) + ? input.choices + : Array.from(input.choices); + + // endDate 타입 체크 + const endDate = input.endDate instanceof Date + ? input.endDate + : input.endDate + ? new Date(input.endDate) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + // allowMultiple 기본값 처리 + const allowMultiple = input.allowMultiple ?? false; + + return { id: generateId(), title: input.title, choices, endDate, status: 'active', allowMultiple }; +} + +function updateVote(id: string, input: Partial): Vote { + const existing = getVote(id); + + // 동일한 타입 체크 반복! ❌ + const choices = input.choices + ? (Array.isArray(input.choices) ? input.choices : Array.from(input.choices)) + : existing.choices; + + const endDate = input.endDate + ? (input.endDate instanceof Date ? input.endDate : new Date(input.endDate)) + : existing.endDate; + + const allowMultiple = input.allowMultiple ?? existing.allowMultiple; + + return { ...existing, choices, endDate, allowMultiple }; +} + +function cloneVote(id: string, overrides?: Partial): Vote { + const original = getVote(id); + + // 또 동일한 타입 체크! ❌ + const choices = overrides?.choices + ? (Array.isArray(overrides.choices) ? overrides.choices : Array.from(overrides.choices)) + : original.choices; + + const endDate = overrides?.endDate + ? (overrides.endDate instanceof Date ? overrides.endDate : new Date(overrides.endDate)) + : original.endDate; + + const allowMultiple = overrides?.allowMultiple ?? original.allowMultiple; + + return { + id: generateId(), + title: overrides?.title ?? `${original.title} (복사본)`, + choices, + endDate, + status: 'active', + allowMultiple + }; +} +``` + +**문제점:** +- 동일한 타입 체크 로직이 세 함수에 반복됨 +- 새로운 필드 추가 시 모든 함수를 수정해야 함 +- 비즈니스 로직과 타입 변환 로직이 섞여 가독성 저하 +- 실수로 한 곳만 수정하면 일관성 깨짐 + +### 🛠️ 해결책 1: Branded Types로 타입 레벨 검증 + +Branded Types는 TypeScript의 구조적 타이핑에서 명목적 타입을 흉내내는 패턴이다. **"이미 검증을 통과한 타입"임을 컴파일 타임에 보장**할 수 있다. + +```typescript +// Branded Type 정의 +type Brand = T & { __brand: B }; + +// 검증된 타입들 +type ValidatedChoices = Brand; +type ValidatedDate = Brand; +type ValidatedBoolean = Brand; + +// 검증 함수 (타입 가드) +function validateChoices(input: string[] | Set): ValidatedChoices { + const choices = Array.isArray(input) ? input : Array.from(input); + + if (choices.length === 0) { + throw new Error('선택지는 최소 1개 이상이어야 합니다'); + } + + if (choices.length > 10) { + throw new Error('선택지는 최대 10개까지 가능합니다'); + } + + // 검증 통과 시 브랜드 타입으로 변환 (런타임 오버헤드 없음) + return choices as ValidatedChoices; +} + +function validateDate(input: Date | string | number | undefined): ValidatedDate { + const date = input instanceof Date + ? input + : input + ? new Date(input) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + if (isNaN(date.getTime())) { + throw new Error('유효하지 않은 날짜입니다'); + } + + if (date < new Date()) { + throw new Error('종료일은 현재 시각 이후여야 합니다'); + } + + return date as ValidatedDate; +} + +function validateAllowMultiple(input: boolean | undefined): ValidatedBoolean { + return (input ?? false) as ValidatedBoolean; +} +``` + +**핵심 포인트:** +- `__brand` 속성은 컴파일 타임에만 존재 (런타임 오버헤드 0) +- 한번 검증을 통과하면 이후 코드에서 재검증 불필요 +- 타입 시스템이 "검증된 값"임을 보장 + +### 🛠️ 해결책 2: Strategy Pattern으로 변환 로직 캡슐화 + +각 타입별 변환 로직을 Strategy로 분리한다. + +```typescript +// Normalizer 인터페이스 (Strategy Pattern) +interface Normalizer { + normalize(input: Input): Output; +} + +// Choices Normalizer +class ChoicesNormalizer implements Normalizer, ValidatedChoices> { + normalize(input: string[] | Set): ValidatedChoices { + return validateChoices(input); + } +} + +// Date Normalizer +class DateNormalizer implements Normalizer { + normalize(input: Date | string | number | undefined): ValidatedDate { + return validateDate(input); + } +} + +// Boolean Normalizer +class BooleanNormalizer implements Normalizer { + normalize(input: boolean | undefined): ValidatedBoolean { + return validateAllowMultiple(input); + } +} +``` + +**장점:** +- 각 변환 로직이 독립적인 클래스로 분리 +- 새로운 변환 로직 추가가 쉬움 +- 테스트하기 쉬움 +- if-else가 클래스 내부로 캡슐화됨 + +### 🛠️ 해결책 3: Factory Pattern으로 생성 로직 통합 + +```typescript +// 엄격한 내부 타입 (모두 필수 + 검증됨) +type ValidatedVoteData = { + title: string; + choices: ValidatedChoices; + endDate: ValidatedDate; + allowMultiple: ValidatedBoolean; +}; + +// Vote Factory +class VoteFactory { + constructor( + private choicesNormalizer = new ChoicesNormalizer(), + private dateNormalizer = new DateNormalizer(), + private booleanNormalizer = new BooleanNormalizer() + ) {} + + // 입력을 검증된 타입으로 변환 + private validate(input: CreateVoteInput): ValidatedVoteData { + return { + title: input.title, + choices: this.choicesNormalizer.normalize(input.choices), + endDate: this.dateNormalizer.normalize(input.endDate), + allowMultiple: this.booleanNormalizer.normalize(input.allowMultiple), + }; + } + + // Vote 생성 (if-else 없음!) + create(input: CreateVoteInput): Vote { + const validated = this.validate(input); + + return { + id: generateId(), + title: validated.title, + choices: validated.choices, + endDate: validated.endDate, + status: 'active', + allowMultiple: validated.allowMultiple, + }; + } + + // 부분 업데이트 (if-else 없음!) + update(existing: Vote, input: Partial): Vote { + const validated: ValidatedVoteData = { + title: input.title ?? existing.title, + choices: input.choices + ? this.choicesNormalizer.normalize(input.choices) + : existing.choices as ValidatedChoices, + endDate: input.endDate + ? this.dateNormalizer.normalize(input.endDate) + : existing.endDate as ValidatedDate, + allowMultiple: input.allowMultiple !== undefined + ? this.booleanNormalizer.normalize(input.allowMultiple) + : existing.allowMultiple as ValidatedBoolean, + }; + + return { ...existing, ...validated }; + } + + // 복제 (if-else 없음!) + clone(original: Vote, overrides?: Partial): Vote { + const input: CreateVoteInput = { + title: overrides?.title ?? `${original.title} (복사본)`, + choices: overrides?.choices ?? original.choices, + endDate: overrides?.endDate ?? original.endDate, + allowMultiple: overrides?.allowMultiple ?? original.allowMultiple, + }; + + return this.create(input); + } +} +``` + +### ✅ 서비스 레이어: 깔끔하고 안전함 + +```typescript +// Factory 인스턴스 (싱글톤 또는 DI) +const voteFactory = new VoteFactory(); + +// 서비스 함수들 - if-else 완전히 제거! +function createVote(input: CreateVoteInput): Vote { + return voteFactory.create(input); +} + +function updateVote(id: string, input: Partial): Vote { + const existing = getVote(id); + return voteFactory.update(existing, input); +} + +function cloneVote(id: string, overrides?: Partial): Vote { + const original = getVote(id); + return voteFactory.clone(original, overrides); +} + +// 사용하는 쪽: 타입 안전 + 깔끔 +const vote1 = createVote({ + title: '점심 메뉴', + choices: ['김치찌개', '된장찌개'], + endDate: '2024-12-31', +}); + +const vote2 = updateVote('v1', { + choices: new Set(['피자', '치킨', '햄버거']), +}); + +const vote3 = cloneVote('v1', { + title: '저녁 메뉴', + endDate: Date.now() + 86400000, +}); +``` + +### 📊 비교: Before vs After + +| 항목 | Before (if-else) | After (Branded + Pattern) | +|------|------------------|---------------------------| +| **타입 체크 반복** | 3곳에서 동일 로직 반복 | 0곳 (Factory가 처리) | +| **새 필드 추가 시** | 3개 함수 모두 수정 | Normalizer 1개만 추가 | +| **비즈니스 로직** | 타입 체크와 섞임 | 명확히 분리됨 | +| **테스트 용이성** | 각 함수마다 테스트 | Normalizer만 테스트 | +| **타입 안정성** | 런타임 검증 의존 | 컴파일 타임 보장 | +| **코드 라인 수** | ~60줄 (중복 포함) | ~40줄 (재사용) | + +### 🎯 실전 적용 가이드 + +**언제 이 패턴을 사용하나?** +- ✅ 동일한 타입 변환 로직이 3곳 이상 반복될 때 +- ✅ 복잡한 검증 로직이 필요할 때 (길이, 범위, 포맷 체크 등) +- ✅ 새로운 필드가 자주 추가되는 도메인일 때 +- ✅ 타입 안정성이 중요한 핵심 도메인일 때 + +**언제 과도한 패턴인가?** +- ❌ 간단한 타입 변환 1-2개만 있을 때 +- ❌ 프로토타입이나 일회성 코드일 때 +- ❌ 팀원들이 패턴에 익숙하지 않을 때 (교육 비용 고려) + +### 💡 추가 활용: Validation Pipe 패턴 + +여러 Normalizer를 조합해서 파이프라인으로 만들 수도 있다. + +```typescript +class ValidationPipeline { + private validators: Array<(input: any) => any> = []; + + pipe(validator: Normalizer): ValidationPipeline { + this.validators.push((input) => validator.normalize(input)); + return this as any; + } + + execute(input: T): any { + return this.validators.reduce((acc, validator) => validator(acc), input); + } +} + +// 사용 예시 +const pipeline = new ValidationPipeline() + .pipe(new ChoicesNormalizer()) + .pipe(new DateNormalizer()) + .pipe(new BooleanNormalizer()); + +const validated = pipeline.execute(input); +``` + +### 정리 + +포스텔의 법칙을 실무에 적용할 때: +1. **입력은 느슨하게** - 다양한 타입 허용 +2. **검증은 한 곳에** - Normalizer/Validator로 캡슐화 +3. **타입은 엄격하게** - Branded Types로 "검증됨" 보장 +4. **로직은 깔끔하게** - Factory/Strategy로 if-else 제거 + +이렇게 하면 **타입 안정성**, **확장성**, **가독성**을 모두 확보할 수 있다. + +## 마치며 + +포스텔의 법칙을 다시 정리하면: +- **매개변수는 너그럽게**: 사용자 편의를 위해 다양한 형태 허용 +- **반환 타입은 엄격하게**: 타입 안정성을 위해 명확한 형태 보장 + +실무에서 자주 보는 실수: +- 입력과 출력에 같은 타입 사용 +- 반환 타입에 과도한 optional과 유니온 +- 외부 API의 느슨한 타입을 그대로 노출 + +투표 시스템 예시로 봤듯이, 입력은 `string[] | Set`처럼 느슨하게 받되, 반환은 `readonly string[]`처럼 엄격하게 돌려주면 사용하는 쪽에서 타입 체크 없이 안전하게 쓸 수 있다. + +--- + +## 보너스: Union Type을 반환해야 할 때의 디자인 패턴 + +지금까지는 "입력은 union, 출력은 단일 타입"을 다뤘다. 하지만 실무에서는 **의도적으로 union type을 반환해야 하는 경우**도 있다. + +### 📌 언제 Union Type을 반환하는가? + +```typescript +// 1. 상태 기반 응답 (State Pattern) +type LoadingState = + | { status: 'loading' } + | { status: 'success', data: T } + | { status: 'error', error: Error }; + +// 2. 다형성 응답 (Polymorphism) +type User = + | { role: 'admin', permissions: string[], department: string } + | { role: 'user', email: string } + | { role: 'guest', sessionId: string }; + +// 3. Result/Either 패턴 (Functional Programming) +type Result = + | { ok: true, value: T } + | { ok: false, error: E }; + +// 4. 비즈니스 로직의 다중 경로 +type VoteResult = + | { status: 'in-progress', currentVotes: number, vote: Vote } + | { status: 'completed', finalResult: VoteStats, vote: Vote } + | { status: 'expired', closedAt: Date, vote: Vote } + | { status: 'not-found', voteId: string }; +``` + +이런 경우 **반환 타입이 union인 것은 정당하다**. 문제는 **사용하는 쪽에서 처리하는 코드가 복잡해진다**는 것이다. + +### ⚠️ 문제: if-else 지옥 + +```typescript +function getVoteResult(id: string): VoteResult { + // ... DB 조회 로직 +} + +// 사용하는 곳 1: UI 렌더링 +function renderVoteStatus(id: string) { + const result = getVoteResult(id); + + if (result.status === 'in-progress') { + return `진행중: ${result.currentVotes}표`; + } else if (result.status === 'completed') { + return `완료: ${formatStats(result.finalResult)}`; + } else if (result.status === 'expired') { + return `종료됨: ${result.closedAt}`; + } else { + return `투표를 찾을 수 없습니다: ${result.voteId}`; + } +} + +// 사용하는 곳 2: 알림 전송 +function sendNotification(id: string) { + const result = getVoteResult(id); + + // 똑같은 if-else 반복! ❌ + if (result.status === 'in-progress') { + notify(`투표 진행중: ${result.currentVotes}표`); + } else if (result.status === 'completed') { + notify(`투표 완료: ${result.finalResult.winner}`); + } else if (result.status === 'expired') { + notify(`투표 종료: ${result.closedAt}`); + } else { + throw new Error(`투표 없음: ${result.voteId}`); + } +} + +// 사용하는 곳 3: 로깅 +function logVoteEvent(id: string) { + const result = getVoteResult(id); + + // 또 똑같은 if-else! ❌ + if (result.status === 'in-progress') { + log('VOTE_PROGRESS', { id, votes: result.currentVotes }); + } else if (result.status === 'completed') { + log('VOTE_COMPLETE', { id, stats: result.finalResult }); + } else if (result.status === 'expired') { + log('VOTE_EXPIRED', { id, closedAt: result.closedAt }); + } else { + log('VOTE_NOT_FOUND', { id: result.voteId }); + } +} +``` + +**문제점:** +- 동일한 타입 체크 로직이 3곳에 반복 +- 새로운 상태 추가 시 모든 사용처를 찾아 수정해야 함 +- 타입 체크를 빼먹으면 런타임 에러 +- 코드 가독성 저하 + +### 🛠️ 해결책 1: Discriminated Union + Type-safe Switch + +가장 기본적인 패턴. TypeScript의 타입 좁히기(narrowing)를 활용한다. + +```typescript +// Exhaustiveness Check 헬퍼 +function assertNever(x: never): never { + throw new Error(`Unexpected value: ${x}`); +} + +// Type-safe reducer +function handleVoteResult( + result: VoteResult, + handlers: { + inProgress: (data: { currentVotes: number, vote: Vote }) => T; + completed: (data: { finalResult: VoteStats, vote: Vote }) => T; + expired: (data: { closedAt: Date, vote: Vote }) => T; + notFound: (data: { voteId: string }) => T; + } +): T { + switch (result.status) { + case 'in-progress': + return handlers.inProgress(result); + case 'completed': + return handlers.completed(result); + case 'expired': + return handlers.expired(result); + case 'not-found': + return handlers.notFound(result); + default: + return assertNever(result); // 컴파일 에러: 빠진 케이스가 있으면! + } +} + +// 사용: if-else 제거됨! ✅ +function renderVoteStatus(id: string) { + return handleVoteResult(getVoteResult(id), { + inProgress: ({ currentVotes }) => `진행중: ${currentVotes}표`, + completed: ({ finalResult }) => `완료: ${formatStats(finalResult)}`, + expired: ({ closedAt }) => `종료됨: ${closedAt}`, + notFound: ({ voteId }) => `투표를 찾을 수 없습니다: ${voteId}`, + }); +} + +function sendNotification(id: string) { + handleVoteResult(getVoteResult(id), { + inProgress: ({ currentVotes }) => notify(`투표 진행중: ${currentVotes}표`), + completed: ({ finalResult }) => notify(`투표 완료: ${finalResult.winner}`), + expired: ({ closedAt }) => notify(`투표 종료: ${closedAt}`), + notFound: ({ voteId }) => { throw new Error(`투표 없음: ${voteId}`); }, + }); +} +``` + +**장점:** +- ✅ Exhaustiveness check로 빠진 케이스 컴파일 타임 감지 +- ✅ if-else 중복 제거 +- ✅ 타입 안전성 보장 +- ✅ 새 상태 추가 시 컴파일러가 알려줌 + +**단점:** +- ❌ 모든 케이스를 항상 처리해야 함 (선택적 처리 불가) +- ❌ 비동기 처리가 복잡함 + +### 🛠️ 해결책 2: Visitor Pattern (객체지향) + +GoF Visitor 패턴. 처리 로직을 외부 객체로 분리한다. + +```typescript +// Visitor 인터페이스 +interface VoteResultVisitor { + visitInProgress(data: { currentVotes: number, vote: Vote }): T; + visitCompleted(data: { finalResult: VoteStats, vote: Vote }): T; + visitExpired(data: { closedAt: Date, vote: Vote }): T; + visitNotFound(data: { voteId: string }): T; +} + +// VoteResult에 accept 메서드 추가 (실제로는 별도 함수로 구현) +function acceptVisitor(result: VoteResult, visitor: VoteResultVisitor): T { + switch (result.status) { + case 'in-progress': + return visitor.visitInProgress(result); + case 'completed': + return visitor.visitCompleted(result); + case 'expired': + return visitor.visitExpired(result); + case 'not-found': + return visitor.visitNotFound(result); + } +} + +// Concrete Visitors +class RenderVisitor implements VoteResultVisitor { + visitInProgress({ currentVotes }: { currentVotes: number }): string { + return `진행중: ${currentVotes}표`; + } + + visitCompleted({ finalResult }: { finalResult: VoteStats }): string { + return `완료: ${formatStats(finalResult)}`; + } + + visitExpired({ closedAt }: { closedAt: Date }): string { + return `종료됨: ${closedAt}`; + } + + visitNotFound({ voteId }: { voteId: string }): string { + return `투표를 찾을 수 없습니다: ${voteId}`; + } +} + +class NotificationVisitor implements VoteResultVisitor { + visitInProgress({ currentVotes }: { currentVotes: number }): void { + notify(`투표 진행중: ${currentVotes}표`); + } + + visitCompleted({ finalResult }: { finalResult: VoteStats }): void { + notify(`투표 완료: ${finalResult.winner}`); + } + + visitExpired({ closedAt }: { closedAt: Date }): void { + notify(`투표 종료: ${closedAt}`); + } + + visitNotFound({ voteId }: { voteId: string }): void { + throw new Error(`투표 없음: ${voteId}`); + } +} + +// 사용 +const result = getVoteResult('v1'); +const message = acceptVisitor(result, new RenderVisitor()); +acceptVisitor(result, new NotificationVisitor()); +``` + +**장점:** +- ✅ Open-Closed Principle: 새로운 visitor 추가 쉬움 +- ✅ 처리 로직이 독립적인 클래스로 분리됨 +- ✅ 복잡한 상태별 로직을 캡슐화 + +**단점:** +- ❌ 보일러플레이트 코드 많음 +- ❌ 타입 추가 시 모든 visitor 수정 필요 +- ❌ 함수형 스타일보다 무거움 + +### 🛠️ 해결책 3: Handler Registry (확장성) + +런타임에 핸들러를 등록/관리하는 패턴. 플러그인 시스템에 적합하다. + +```typescript +// Handler 타입 정의 +type VoteResultHandler = { + [K in VoteResult['status']]: ( + data: Extract + ) => T; +}; + +// Handler Registry +class VoteResultHandlerRegistry { + private handlers = new Map>(); + + register(name: string, handler: VoteResultHandler): void { + this.handlers.set(name, handler); + } + + handle(name: string, result: VoteResult): T { + const handler = this.handlers.get(name); + if (!handler) { + throw new Error(`Handler not found: ${name}`); + } + + switch (result.status) { + case 'in-progress': + return handler['in-progress'](result); + case 'completed': + return handler['completed'](result); + case 'expired': + return handler['expired'](result); + case 'not-found': + return handler['not-found'](result); + } + } +} + +// 글로벌 레지스트리 +const registry = new VoteResultHandlerRegistry(); + +// 핸들러 등록 +registry.register('render', { + 'in-progress': ({ currentVotes }) => `진행중: ${currentVotes}표`, + 'completed': ({ finalResult }) => `완료: ${formatStats(finalResult)}`, + 'expired': ({ closedAt }) => `종료됨: ${closedAt}`, + 'not-found': ({ voteId }) => `투표를 찾을 수 없습니다: ${voteId}`, +}); + +registry.register('notify', { + 'in-progress': ({ currentVotes }) => notify(`투표 진행중: ${currentVotes}표`), + 'completed': ({ finalResult }) => notify(`투표 완료: ${finalResult.winner}`), + 'expired': ({ closedAt }) => notify(`투표 종료: ${closedAt}`), + 'not-found': ({ voteId }) => { throw new Error(`투표 없음: ${voteId}`); }, +}); + +// 사용 +const result = getVoteResult('v1'); +const message = registry.handle('render', result); +registry.handle('notify', result); +``` + +**장점:** +- ✅ 런타임 확장 가능 (플러그인 아키텍처) +- ✅ 핸들러를 동적으로 추가/제거 가능 +- ✅ 중앙 집중식 관리 + +**단점:** +- ❌ 타입 안전성 약함 (any 사용) +- ❌ 런타임 에러 가능성 (핸들러 미등록) +- ❌ 컴파일 타임 체크 불가 + +### 🛠️ 해결책 4: Result/Either Monad (함수형) + +함수형 프로그래밍의 Result/Either 타입. 에러 처리를 우아하게 체이닝한다. + +```typescript +// Result 타입 (간단한 구현) +class Result { + constructor(private value: VoteResult) {} + + map(fn: (result: VoteResult) => U): Result { + return new Result(fn(this.value) as any); + } + + flatMap(fn: (result: VoteResult) => Result): Result { + return fn(this.value); + } + + // Pattern matching + match(handlers: { + inProgress?: (data: Extract) => U; + completed?: (data: Extract) => U; + expired?: (data: Extract) => U; + notFound?: (data: Extract) => U; + default?: (data: VoteResult) => U; + }): U { + const result = this.value; + + switch (result.status) { + case 'in-progress': + return handlers.inProgress?.(result) ?? handlers.default?.(result) as U; + case 'completed': + return handlers.completed?.(result) ?? handlers.default?.(result) as U; + case 'expired': + return handlers.expired?.(result) ?? handlers.default?.(result) as U; + case 'not-found': + return handlers.notFound?.(result) ?? handlers.default?.(result) as U; + default: + return handlers.default?.(result) as U; + } + } + + unwrap(): VoteResult { + return this.value; + } +} + +// 사용: 함수형 체이닝 ✅ +function renderVoteStatus(id: string): string { + return new Result(getVoteResult(id)) + .match({ + inProgress: ({ currentVotes }) => `진행중: ${currentVotes}표`, + completed: ({ finalResult }) => `완료: ${formatStats(finalResult)}`, + expired: ({ closedAt }) => `종료됨: ${closedAt}`, + notFound: ({ voteId }) => `투표를 찾을 수 없습니다: ${voteId}`, + }); +} + +// Optional handler로 부분 처리 가능 +function handleOnlyActive(id: string): string { + return new Result(getVoteResult(id)) + .match({ + inProgress: ({ currentVotes }) => `활성 투표: ${currentVotes}표`, + default: () => `비활성 투표`, + }); +} +``` + +**장점:** +- ✅ 함수형 체이닝으로 우아함 +- ✅ 선택적 핸들러 가능 (default 케이스) +- ✅ map/flatMap으로 변환 체인 구성 가능 +- ✅ 에러 처리와 성공 케이스 분리 + +**단점:** +- ❌ 러닝 커브 (Monad 개념 필요) +- ❌ 팀원들이 익숙하지 않을 수 있음 +- ❌ 라이브러리 (fp-ts, neverthrow) 의존도 고려 + +### 🛠️ 해결책 5: Pattern Matching with ts-pattern (최신) + +TypeScript의 패턴 매칭 라이브러리 `ts-pattern`을 사용하면 가장 우아한 API를 얻을 수 있다. + +```typescript +import { match } from 'ts-pattern'; + +// 사용: 가장 간결하고 타입 안전함! ✅✅ +function renderVoteStatus(id: string): string { + return match(getVoteResult(id)) + .with({ status: 'in-progress' }, ({ currentVotes }) => + `진행중: ${currentVotes}표` + ) + .with({ status: 'completed' }, ({ finalResult }) => + `완료: ${formatStats(finalResult)}` + ) + .with({ status: 'expired' }, ({ closedAt }) => + `종료됨: ${closedAt}` + ) + .with({ status: 'not-found' }, ({ voteId }) => + `투표를 찾을 수 없습니다: ${voteId}` + ) + .exhaustive(); // 모든 케이스 처리 강제 +} + +// 부분 매칭 (선택적 처리) +function handleOnlyActive(id: string): string { + return match(getVoteResult(id)) + .with({ status: 'in-progress' }, ({ currentVotes }) => + `활성 투표: ${currentVotes}표` + ) + .otherwise(() => `비활성 투표`); +} + +// 복잡한 패턴도 가능 +function categorizeVote(id: string): string { + return match(getVoteResult(id)) + .with({ status: 'in-progress', currentVotes: 0 }, () => + `투표 시작됨 (아직 투표 없음)` + ) + .with({ status: 'in-progress' }, ({ currentVotes }) => + `진행중: ${currentVotes}표` + ) + .with({ status: 'completed', finalResult: { winner: 'A' } }, () => + `A가 승리!` + ) + .with({ status: 'completed' }, ({ finalResult }) => + `완료: ${finalResult.winner}` + ) + .otherwise(() => `기타`); +} + +// 가드 패턴 +import { P } from 'ts-pattern'; + +function checkVoteUrgency(id: string): string { + const now = new Date(); + + return match(getVoteResult(id)) + .with( + { status: 'in-progress', vote: { endDate: P.when(d => d < now) } }, + () => `긴급: 투표 종료 임박!` + ) + .with({ status: 'in-progress' }, () => `정상 진행중`) + .otherwise(() => `종료된 투표`); +} +``` + +**장점:** +- ✅ 가장 간결하고 읽기 쉬운 API +- ✅ Exhaustiveness check 내장 (`.exhaustive()`) +- ✅ 복잡한 패턴 매칭 지원 (중첩, 가드, 와일드카드) +- ✅ 완벽한 타입 추론 및 타입 안전성 +- ✅ 선택적 처리 (`.otherwise()`) + +**단점:** +- ❌ 외부 라이브러리 의존성 +- ❌ 번들 사이즈 증가 (약 10KB) + +### 📊 패턴 비교표 + +| 패턴 | 타입 안전성 | 코드 간결성 | 확장성 | 러닝 커브 | 외부 의존성 | 추천 상황 | +|------|-----------|-----------|-------|---------|-----------|---------| +| **Type-safe Switch** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | 없음 | 기본 케이스, 팀 표준 | +| **Visitor Pattern** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | 없음 | 복잡한 도메인, OOP 환경 | +| **Handler Registry** | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 없음 | 플러그인 시스템, 런타임 확장 | +| **Result Monad** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | fp-ts/neverthrow | 함수형 코드베이스, 에러 처리 | +| **ts-pattern** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ts-pattern | 모던 프로젝트, 복잡한 패턴 매칭 | + +### 🎯 선택 가이드 + +**1. 시작은 Type-safe Switch로** +- 외부 의존성 없음 +- TypeScript 기본 기능만 사용 +- 팀원 모두가 이해 가능 +- 대부분의 경우 충분함 + +**2. 복잡도가 높아지면 ts-pattern으로** +- Union type이 5개 이상 +- 중첩된 패턴 매칭 필요 +- 가드 조건이 많음 +- 가독성이 중요한 비즈니스 로직 + +**3. OOP 프로젝트라면 Visitor Pattern** +- 이미 클래스 기반 아키텍처 +- 처리 로직이 복잡하고 독립적 +- Open-Closed Principle 준수 + +**4. 플러그인 시스템이라면 Handler Registry** +- 런타임에 핸들러 추가/제거 필요 +- 서드파티 확장 지원 +- 동적 기능 로딩 + +**5. 함수형 코드베이스라면 Result Monad** +- 이미 fp-ts나 Ramda 사용중 +- 에러 처리가 핵심 +- 데이터 파이프라인 구성 + +### 💡 실전 조합: Layered Architecture + +실무에서는 여러 패턴을 레이어별로 조합한다. + +```typescript +// 1. Domain Layer: 엄격한 타입 정의 +type VoteResult = + | { status: 'in-progress', currentVotes: number, vote: Vote } + | { status: 'completed', finalResult: VoteStats, vote: Vote } + | { status: 'expired', closedAt: Date, vote: Vote } + | { status: 'not-found', voteId: string }; + +// 2. Service Layer: Type-safe handler로 처리 +function handleVoteResult( + result: VoteResult, + handlers: { /* ... */ } +): T { + // Type-safe switch 사용 +} + +// 3. Presentation Layer: ts-pattern으로 UI 로직 +function VoteStatusComponent({ id }: { id: string }) { + const result = getVoteResult(id); + + return match(result) + .with({ status: 'in-progress' }, ({ currentVotes }) => ( + + )) + .with({ status: 'completed' }, ({ finalResult }) => ( + + )) + .with({ status: 'expired' }, ({ closedAt }) => ( + + )) + .with({ status: 'not-found' }, ({ voteId }) => ( + + )) + .exhaustive(); +} + +// 4. Infrastructure Layer: Registry로 확장 포인트 +const eventHandlers = new VoteResultHandlerRegistry(); + +// 플러그인이 핸들러 등록 +eventHandlers.register('analytics', { + 'in-progress': ({ currentVotes }) => trackEvent('vote_progress', { currentVotes }), + 'completed': ({ finalResult }) => trackEvent('vote_complete', { finalResult }), + // ... +}); +``` + +**레이어별 역할:** +- **Domain**: 타입 정의만 (패턴 X) +- **Service**: Type-safe switch (핵심 로직) +- **Presentation**: ts-pattern (UI 분기, 가독성) +- **Infrastructure**: Registry (확장성, 플러그인) + +### 정리: Union Type 반환값 처리 체크리스트 + +**✅ Union Type 반환이 정당한가?** +- [ ] 비즈니스 로직상 여러 상태/경로가 존재하는가? +- [ ] 각 케이스가 서로 다른 데이터를 가지는가? +- [ ] 단일 타입으로 통합할 수 없는가? + +**✅ Discriminated Union으로 설계했는가?** +- [ ] `status`, `type`, `kind` 같은 tag 필드가 있는가? +- [ ] Tag 값이 리터럴 타입인가? (string 아니고 'success' | 'error') +- [ ] 각 케이스의 데이터 구조가 명확한가? + +**✅ 처리 로직이 중복되지 않는가?** +- [ ] if-else/switch가 3곳 이상 반복되는가? → 패턴 적용 +- [ ] 새 케이스 추가 시 모든 곳을 수정해야 하는가? → 중앙화 필요 +- [ ] Exhaustiveness check가 있는가? + +**✅ 적절한 패턴을 선택했는가?** +- [ ] 외부 의존성 허용되는가? → ts-pattern 고려 +- [ ] 팀원들이 패턴에 익숙한가? → Type-safe switch +- [ ] 확장성이 중요한가? → Visitor 또는 Registry + +--- + +## 최종 정리 + +포스텔의 법칙과 Union Type 처리를 종합하면: + +### 📥 입력 (매개변수) +- **너그럽게**: 다양한 타입 허용 (`string | number | Date`) +- **정규화**: 한 곳에서 엄격한 타입으로 변환 +- **패턴**: Factory, Normalizer, Branded Types + +### 📤 출력 (반환 타입) +- **원칙**: 가능하면 단일 타입으로 (`Date`, `readonly string[]`) +- **예외**: 비즈니스 로직상 Union이 정당한 경우 +- **패턴**: Type-safe switch, ts-pattern, Visitor, Result Monad + +### 🎯 핵심 원칙 +1. **타입 안전성**: 컴파일 타임에 최대한 검증 +2. **중복 제거**: 타입 체크 로직을 한 곳에 +3. **확장성**: 새 케이스 추가가 쉽게 +4. **가독성**: 비즈니스 로직이 명확히 드러나게 + +혹시 다른 좋은 실무 사례나 패턴이 있다면 공유 부탁드립니다. + + +# 아이템 30: 문서에 타입 정보를 쓰지 않기 + +## 들어가며 + +타입스크립트를 처음 배울 때는 주석을 열심히 달아야 한다고 생각했다. 특히 함수의 매개변수나 반환값 타입을 주석으로 설명하면 친절한 코드라고 착각했다. 하지만 이건 완전히 잘못된 생각이었다. + +이번 글에서는 왜 주석과 변수명에 타입 정보를 적으면 안 되는지, 그리고 올바른 방법은 무엇인지 상세히 설명하겠다. + +## ⚠️ 문제 상황: 주석과 코드의 불일치 + +다음 코드를 살펴보자. + +```typescript +/** + * 전경색(foreground) 문자열을 반환합니다. + * 0개 또는 1개의 매개변수를 받습니다. + * 매개변수가 없을 때는 표준 전경색을 반환합니다. + * 매개변수가 있을 때는 특정 페이지의 전경색을 반환합니다. + */ +function getForegroundColor(page?: string) { + return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0}; +} +``` + +뭔가 이상하지 않은가? 주석과 실제 코드가 전혀 맞지 않는다. + +### 세 가지 문제점 + +이 코드의 주석에는 세 가지 문제점이 있다: + +1. **반환 타입 불일치**: 주석에는 "문자열을 반환합니다"라고 적혀있지만, 실제로는 `{r, g, b}` 객체를 반환한다. + +2. **불필요한 매개변수 설명**: "0개 또는 1개의 매개변수를 받습니다"라고 설명하는데, 타입 시그니처(`page?: string`)만 봐도 알 수 있는 정보다. + +3. **장황함**: 주석이 함수 선언과 구현체보다 더 길다. 불필요하게 장황한 설명은 오히려 가독성을 해친다. + +아마도 이 함수는 과거에 문자열을 반환했다가 나중에 객체를 반환하도록 변경되었을 것이다. 그런데 코드를 수정한 사람이 주석 업데이트를 깜빡한 것으로 보인다. + +### 왜 이런 일이 발생하는가? + +누군가 강제하지 않는 이상 **주석은 코드와 동기화되지 않는다**. + +반면, 타입 구문은 타입스크립트 컴파일러가 강제로 동기화시킨다. 코드가 변경되면 타입 정보도 반드시 함께 업데이트해야 컴파일이 되기 때문이다. + +## 🛠️ 해결 방법 1: 주석에서 타입 정보 제거 + +타입스크립트의 타입 시스템은 간결하고 구체적이며 읽기 쉽게 설계되었다. 함수의 입력과 출력 타입을 코드로 표현하는 것이 주석보다 훨씬 낫다. + +```typescript +// ✅ 개선된 코드 +/** 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다. */ +function getForegroundColor(page?: string): Color { + return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0}; +} +``` + +주석에서는 타입 정보를 완전히 제거하고, 함수의 **목적**만 간결하게 설명한다. 타입 정보는 타입 시스템이 알아서 제공한다. + +만약 특정 매개변수를 설명하고 싶다면 JSDoc의 `@param` 구문을 사용하면 된다. + +```typescript +/** + * 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다. + * @param page - 페이지 이름 (선택사항). 생략 시 기본 전경색 반환 + */ +function getForegroundColor(page?: string): Color { + return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0}; +} +``` + +## 🛠️ 해결 방법 2: readonly로 불변성 강제 + +"값을 변경하지 않는다"는 주석도 좋지 않다. + +```typescript +// ❌ 나쁜 예시 +/** nums를 변경하지 않습니다. */ +function sort(nums: number[]) { + // ... +} +``` + +주석 대신 `readonly`로 선언해서 타입스크립트가 규칙을 강제하도록 하자. + +```typescript +// ✅ 좋은 예시 +function sort(nums: readonly number[]) { + // ... +} +``` + +이렇게 하면 함수 내부에서 `nums`를 수정하려고 하면 컴파일 에러가 발생한다. 주석은 거짓말할 수 있지만, 타입 시스템은 거짓말하지 않는다. + +## 변수명 작성 규칙 + +주석에 적용한 규칙은 변수명에도 그대로 적용된다. **변수명에 타입 정보를 넣지 않는다**. + +```typescript +// ❌ 나쁜 예시 +const ageNum = 25; +const nameStr = "Roky"; + +// ✅ 좋은 예시 +const age: number = 25; +const name: string = "Roky"; +``` + +변수명은 `ageNum`이 아니라 `age`로 하고, 타입이 `number`임을 타입 시스템으로 명시하는 게 낫다. + +### 예외: 단위가 있는 숫자 + +그러나 단위가 있는 숫자들은 예외다. 단위가 무엇인지 확실하지 않다면 변수명이나 속성 이름에 단위를 포함할 수 있다. + +```typescript +// ✅ 단위를 명시하는 것은 좋다 +const timeMs = 5000; // time보다 훨씬 명확 +const temperatureC = 36.5; // temperature보다 훨씬 명확 +const delaySeconds = 3; +``` + +`time`이라는 변수명만 보면 초인지 밀리초인지 알 수 없다. 하지만 `timeMs`라고 적으면 밀리초 단위임을 바로 알 수 있다. + +## 핵심 원칙 정리 + +1. **주석에 타입 정보를 적지 마라**: 타입 시스템이 이미 제공한다. +2. **변수명에 타입 정보를 넣지 마라**: `ageNum` 대신 `age: number`를 사용하라. +3. **불변성은 주석이 아닌 `readonly`로 강제하라**: 주석은 거짓말할 수 있지만 타입 시스템은 거짓말하지 않는다. +4. **단위가 있는 숫자는 예외다**: `timeMs`, `temperatureC`처럼 단위를 변수명에 포함시켜라. + +## 마치며 + +처음에는 주석을 많이 달아야 좋은 코드라고 생각했는데, 타입스크립트에서는 오히려 역효과를 낳을 수 있다는 걸 알게 되었다. + +타입 시스템을 믿고 주석은 "왜"와 "무엇을 위해"에만 집중하는 게 좋겠다. 타입 정보는 컴파일러에게 맡기고, 우리는 비즈니스 로직에 집중하자. + +혹시 주석과 타입 시스템의 균형을 맞추는 더 좋은 방법을 아시는 분이 계시다면 조언해주시면 정말 감사하겠습니다. + +# 아이템 31: 타입 주변에 null 값 배치하기 + +## 들어가며 + +strictNullChecks를 처음 켰을 때를 떠올려보자. 갑자기 null이나 undefined 관련 오류가 쏟아지면서 "아, null 체크 if 구문을 코드 전체에 추가해야 하는구나"라고 생각했을 것이다. 나도 그랬다. + +하지만 문제는 단순히 if 구문을 추가하는 게 아니다. **어떤 변수가 null이 될 수 있는지 없는지를 타입만으로는 명확하게 표현하기 어렵다**는 게 핵심이다. 예를 들어 변수 B가 변수 A의 값으로부터 나온 값이라면, A가 null일 때 B도 null이고, A가 null이 아닐 때 B도 null이 아니다. 이런 관계는 겉으로 드러나지 않아서 사람과 타입 체커 모두를 헷갈리게 만든다. + +이번 글에서는 null 값을 어떻게 다루는 게 좋은지, 구체적인 예제와 함께 알아보자. + +## ⚠️ 문제 상황 1: extent 함수의 버그 + +숫자 배열의 최솟값과 최댓값을 계산하는 extent 함수를 가정해보자. + +```typescript +// strictNullChecks 없이 작성한 코드 +function extent(nums: number[]) { + let min, max; + for (const num of nums) { + if (!min) { + min = num; + max = num; + } else { + min = Math.min(min, num); + max = Math.max(max, num); + } + } + return [min, max]; +} +``` + +이 코드는 타입 체커를 통과하고 (strictNullChecks 없이), 반환 타입은 `number[]`로 추론된다. 하지만 여기에는 **두 가지 치명적인 버그**가 있다: + +1. **0이 최솟값이나 최댓값일 때 값이 덮어써진다** + - `extent([0, 1, 2])`의 결과는 `[0, 2]`가 아니라 `[1, 2]`가 된다 + - `!min`은 min이 0일 때도 true가 되기 때문이다 + +2. **빈 배열을 넘기면 `[undefined, undefined]`를 반환한다** + - undefined를 포함하는 객체는 다루기 어렵고 절대 권장하지 않는다 + - min과 max가 동시에 둘 다 undefined이거나 둘 다 undefined가 아니라는 정보를 타입 시스템이 표현할 수 없다 + +strictNullChecks를 켜면 이 문제점이 드러난다: + +```typescript +function extent(nums: number[]) { + let min, max; + for (const num of nums) { + if (!min) { + min = num; + max = num; + } else { + min = Math.min(min, num); + max = Math.max(max, num); + // ~~~ 'number | undefined' 형식의 인수는 + // 'number' 형식의 매개변수에 할당될 수 없습니다. + } + } + return [min, max]; +} +``` + +반환 타입이 `(number | undefined)[]`로 추론되어서 설계적 결함이 명확해졌다. extent를 호출하는 곳마다 타입 오류가 발생한다: + +```typescript +const [min, max] = extent([0, 1, 2]); +const span = max - min; +// ~~~ ~~~ 개체가 'undefined'인 것 같습니다. +``` + +## 🛠️ 해결 방법 1: 단일 객체로 묶기 + +문제의 근본 원인은 **min과 max가 동시에 초기화되지만, 타입 시스템이 이를 표현할 수 없다**는 것이다. max에 대한 체크를 추가해서 오류를 해결할 수도 있지만, 그러면 버그가 두 배로 늘어날 것이다. + +더 나은 해법을 찾아보자. **min과 max를 한 객체 안에 넣고 전부 null이거나 전부 null이 아니게** 만들면 된다. + +```typescript +// ✅ 개선된 extent 함수 +function extent(nums: number[]): [number, number] | null { + let result: [number, number] | null = null; + + for (const num of nums) { + if (!result) { + // 첫 번째 숫자로 min, max 초기화 + result = [num, num]; + } else { + // result[0]은 min, result[1]은 max + result = [Math.min(num, result[0]), Math.max(num, result[1])]; + } + } + + return result; +} +``` + +이제 반환 타입이 `[number, number] | null`이 되어서 **사용하기가 훨씬 수월해졌다**. + +### 사용 예시 1: null 아님 단언 사용 + +```typescript +const [min, max] = extent([0, 1, 2])!; +const span = max - min; // 정상 +``` + +### 사용 예시 2: if 구문으로 체크 + +```typescript +const range = extent([0, 1, 2]); +if (range) { + const [min, max] = range; + const span = max - min; // 정상 +} +``` + +extent의 결과로 단일 객체를 사용함으로써: +- ✅ 설계를 개선했고 +- ✅ 타입스크립트가 null 값 사이의 관계를 이해할 수 있게 했으며 +- ✅ 버그도 제거했다 + +`if (!result)` 체크는 이제 제대로 동작한다. + +## ⚠️ 문제 상황 2: UserPosts 클래스의 null 혼재 + +null과 null이 아닌 값을 섞어서 사용하면 클래스에서도 문제가 생긴다. 사용자와 그 사용자의 포럼 게시글을 나타내는 클래스를 가정해보자. + +```typescript +// ❌ null이 섞인 설계 +class UserPosts { + user: UserInfo | null; + posts: Post[] | null; + + constructor() { + this.user = null; + this.posts = null; + } + + async init(userId: string) { + return Promise.all([ + async () => this.user = await fetchUser(userId), + async () => this.posts = await fetchPostsForUser(userId) + ]); + } + + getUserName() { + // user가 null일 수도 있어서 체크 필요 + // posts도 null일 수도 있어서 체크 필요 + // 메서드마다 null 체크 지옥... + } +} +``` + +두 번의 네트워크 요청이 로드되는 동안 user와 posts 속성은 null 상태다. 어떤 시점에는: +- 둘 다 null이거나 +- user만 null이거나 +- posts만 null이거나 +- 둘 다 null이 아니거나 + +**총 4가지 경우가 존재한다.** 속성값의 불확실성이 클래스의 모든 메서드에 나쁜 영향을 미친다. 결국 null 체크가 난무하고 버그를 양산하게 된다. + +## 🛠️ 해결 방법 2: 데이터 준비 후 생성 + +설계를 개선해보자. **필요한 데이터가 모두 준비된 후에 클래스를 만들도록** 바꾸면 된다. + +```typescript +// ✅ null이 없는 설계 +class UserPosts { + user: UserInfo; + posts: Post[]; + + constructor(user: UserInfo, posts: Post[]) { + this.user = user; + this.posts = posts; + } + + // static 팩토리 메서드로 데이터를 먼저 가져온 후 인스턴스 생성 + static async init(userId: string): Promise { + const [user, posts] = await Promise.all([ + fetchUser(userId), + fetchPostsForUser(userId) + ]); + return new UserPosts(user, posts); + } + + getUserName() { + // user는 항상 존재하므로 null 체크 불필요 + return this.user.name; + } +} +``` + +이제 UserPosts 클래스는 **완전히 null이 아니게 되었고**, 메서드를 작성하기 쉬워졌다. + +물론 데이터가 부분적으로 준비되었을 때 작업을 시작해야 한다면, null과 null이 아닌 경우의 상태를 다루어야 한다. 하지만 가능하면 이런 설계를 피하는 게 좋다. + +> ⚠️ **주의**: null인 경우가 필요한 속성을 Promise로 바꾸면 안 된다. 코드가 매우 복잡해지며 모든 메서드가 비동기로 바뀌어야 한다. Promise는 데이터를 로드하는 코드를 단순하게 만들어주지만, 데이터를 사용하는 클래스에서는 반대로 코드가 복잡해지는 효과를 낸다. + +## 핵심 원칙 + +null을 다룰 때는 다음 3가지 원칙을 기억하자: + +1. **한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하지 마라** + - min과 max처럼 동시에 null이거나 아닌 값들은 하나의 객체로 묶어라 + +2. **API 작성 시 반환 타입을 큰 객체로 만들고, 반환 타입 전체가 null이거나 null이 아니게 만들어라** + - `[number | undefined, number | undefined]` ❌ + - `[number, number] | null` ✅ + +3. **클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하라** + - 생성자에서 null을 받지 말고, static 팩토리 메서드에서 데이터를 먼저 가져와라 + +## 마치며 + +strictNullChecks를 처음 켰을 때는 오류가 너무 많이 나와서 당황했지만, 이런 원칙들을 알고 나니 null을 다루는 게 훨씬 명확해졌다. **값이 전부 null이거나 전부 null이 아닌 경우로 분명히 구분**하면, 값이 섞여 있을 때보다 다루기 쉽다는 걸 몸소 느꼈다. + +strictNullChecks를 설정하면 코드에 많은 오류가 표시되겠지만, null 값과 관련된 문제점을 찾아낼 수 있기 때문에 반드시 필요하다. 처음엔 귀찮지만, 나중에 런타임 오류를 방지해준다고 생각하면 투자할 가치가 충분하다. + +혹시 null 처리에 대해 더 좋은 패턴이나 경험을 아시는 분이 계시다면 조언해주시면 정말 감사하겠습니다! + +# 아이템 32: 유니온의 인터페이스보다 인터페이스의 유니온을 사용하자 + +## 도입 + +타입을 설계하다 보면 "이 속성들이 서로 관련이 있는데, 타입스크립트가 이걸 제대로 체크해주지 못하네?"라는 상황을 만나게 된다. 특히 유니온 타입의 속성을 여러 개 가진 인터페이스를 작성할 때 그렇다. + +이번 글에서는 유니온 타입을 올바르게 사용하는 방법에 대해 알아보겠다. 구체적으로는 **유니온의 인터페이스**가 아닌 **인터페이스의 유니온**을 사용해야 하는 이유와 방법을 다룬다. + +## ⚠️ 문제 상황: 유니온의 인터페이스 + +벡터 그래픽 프로그램에서 레이어를 표현하는 타입을 설계한다고 가정해보자. 각 레이어는 `layout`(모양과 위치)과 `paint`(스타일)를 가진다. + +```typescript +// ❌ 잘못된 설계 +interface Layer { + layout: FillLayout | LineLayout | PointLayout; + paint: FillPaint | LinePaint | PointPaint; +} +``` + +이 설계의 문제점이 뭘까? + +`layout`이 `LineLayout`인데 `paint`가 `FillPaint`인 상황을 생각해보자. 선(Line)을 그리는데 면(Fill) 스타일을 적용한다? 이건 말이 안 된다. 하지만 위 타입 정의는 이런 **잘못된 조합을 허용**한다. + +```typescript +// 타입스크립트는 이걸 허용하지만, 실제로는 말이 안 되는 조합 +const invalidLayer: Layer = { + layout: lineLayout, // 선 레이아웃 + paint: fillPaint // 면 페인트 ← 이상한 조합! +}; +``` + +이런 식으로 설계하면: +- 런타임 오류가 발생하기 쉽다 +- 라이브러리 사용자가 혼란스럽다 +- 타입 체커가 제대로 도움을 주지 못한다 + +## 🛠️ 해결 방법: 인터페이스의 유니온 + +더 나은 방법은 각 타입의 조합을 **개별 인터페이스**로 분리하는 것이다. + +```typescript +// ✅ 개선된 설계 +interface FillLayer { + layout: FillLayout; + paint: FillPaint; +} + +interface LineLayer { + layout: LineLayout; + paint: LinePaint; +} + +interface PointLayer { + layout: PointLayout; + paint: PointPaint; +} + +// 인터페이스의 유니온 +type Layer = FillLayer | LineLayer | PointLayer; +``` + +이제 `layout`과 `paint`가 잘못된 조합으로 섞일 수 없다. **유효한 상태만** 표현할 수 있게 되었다. + +```typescript +// ✅ 올바른 조합만 가능 +const fillLayer: Layer = { + layout: fillLayout, + paint: fillPaint +}; + +const lineLayer: Layer = { + layout: lineLayout, + paint: linePaint +}; + +// ❌ 이제 이런 조합은 타입 에러 +const invalid: Layer = { + layout: lineLayout, + paint: fillPaint // 타입 에러! +}; +``` + +## 태그된 유니온 (Tagged Union) + +위 패턴의 가장 일반적인 형태는 **태그된 유니온**이다. 각 인터페이스에 `type` 같은 리터럴 타입 속성을 추가하는 방식이다. + +```typescript +interface FillLayer { + type: 'fill'; // 태그 + layout: FillLayout; + paint: FillPaint; +} + +interface LineLayer { + type: 'line'; // 태그 + layout: LineLayout; + paint: LinePaint; +} + +interface PointLayer { + type: 'point'; // 태그 + layout: PointLayout; + paint: PointPaint; +} + +type Layer = FillLayer | LineLayer | PointLayer; +``` + +태그를 사용하면 런타임에 어떤 타입인지 판단할 수 있고, 타입스크립트가 **타입 좁히기**(narrowing)를 수행할 수 있다. + +```typescript +function drawLayer(layer: Layer) { + if (layer.type === 'fill') { + const { paint } = layer; // 타입: FillPaint + const { layout } = layer; // 타입: FillLayout + } else if (layer.type === 'line') { + const { paint } = layer; // 타입: LinePaint + const { layout } = layer; // 타입: LineLayout + } else { + const { paint } = layer; // 타입: PointPaint + const { layout } = layer; // 타입: PointLayout + } +} +``` + +타입 체커가 각 분기에서 정확한 타입을 추론해준다. 이게 **태그된 유니온의 강력한 점**이다. + +## 관련 속성은 객체로 묶자 + +선택적 필드 여러 개가 **동시에 있거나 동시에 없는** 경우에도 비슷한 패턴을 적용할 수 있다. + +```typescript +// ❌ 잘못된 설계 +interface Person { + name: string; + // 둘 다 동시에 있거나 동시에 없어야 함 + placeOfBirth?: string; + dateOfBirth?: Date; +} +``` + +위 코드의 문제점: +- `placeOfBirth`만 있고 `dateOfBirth`는 없는 상황 허용 +- 속성 간 관계가 타입에 표현되지 않음 +- 주석으로 관계를 설명 → 타입 체커가 검증 불가 + +더 나은 설계는 관련된 속성을 **하나의 객체로 묶는** 것이다. + +```typescript +// ✅ 개선된 설계 +interface Person { + name: string; + birth?: { + place: string; + date: Date; + } +} +``` + +이제 `place`만 있고 `date`가 없는 경우는 타입 에러가 발생한다. + +```typescript +// ❌ 타입 에러 +const alan: Person = { + name: 'Alan Turing', + birth: { + place: 'London' + // 'date' 속성이 필수인데 없음! + } +}; + +// ✅ 올바른 사용 +const alan: Person = { + name: 'Alan Turing', + birth: { + place: 'London', + date: new Date('1912-06-23') + } +}; +``` + +함수에서도 `birth` 하나만 체크하면 된다. + +```typescript +function eulogize(p: Person) { + console.log(p.name); + const { birth } = p; + + if (birth) { + // birth가 있으면 place와 date 모두 있음이 보장됨 + console.log(`was born on ${birth.date} in ${birth.place}.`); + } +} +``` + +## 구조를 바꿸 수 없을 때 + +API 응답처럼 **타입 구조를 변경할 수 없는** 상황이라면? 인터페이스의 유니온으로 모델링할 수 있다. + +```typescript +interface Name { + name: string; +} + +interface PersonWithBirth extends Name { + placeOfBirth: string; + dateOfBirth: Date; +} + +// 인터페이스의 유니온 +type Person = Name | PersonWithBirth; +``` + +이제 타입 가드로 속성 간 관계를 검증할 수 있다. + +```typescript +function eulogize(p: Person) { + if ('placeOfBirth' in p) { + // 타입이 PersonWithBirth로 좁혀짐 + const { dateOfBirth } = p; // ✅ 정상, 타입: Date + console.log(`was born on ${dateOfBirth} in ${p.placeOfBirth}.`); + } +} +``` + +`placeOfBirth`가 있으면 `dateOfBirth`도 반드시 있다는 것을 타입 시스템이 보장한다. + +## 마치며 + +타입 설계 원칙을 정리하면: + +1. **유니온의 인터페이스**보다 **인터페이스의 유니온**을 사용하자 +2. 속성 간 관계가 있다면 **태그된 유니온**을 고려하자 +3. 관련된 선택적 필드는 **하나의 객체로 묶자** +4. 타입 구조 변경이 불가능하면 **타입 가드**를 활용하자 + +이 패턴들을 잘 활용하면 타입스크립트가 더 많은 오류를 컴파일 타임에 잡아줄 수 있다. 유효한 상태만 표현하는 타입 설계는 런타임 오류를 줄이고 코드의 안정성을 높인다. + +혹시 타입 설계나 유니온 타입 활용에 대해 더 좋은 방법을 아시는 분이 계시다면 조언해주시면 정말 감사하겠습니다. diff --git a/practice/practice.ts b/practice/practice.ts new file mode 100644 index 0000000..468b1bb --- /dev/null +++ b/practice/practice.ts @@ -0,0 +1,6 @@ +// 이 코드는 typescript로 파일명만 바꿔서 실행 가능 +function greet(who) { + return `Hello ${who}`; +} + +console.log(greet("roky")); diff --git a/practice/practice1.ts b/practice/practice1.ts new file mode 100644 index 0000000..45fabee --- /dev/null +++ b/practice/practice1.ts @@ -0,0 +1,6 @@ +// 이 코드는 javascript로 파일명 바꾸면 실행 불가능 +function greet(who: string) { + return `Hello ${who}`; +} + +console.log(greet("roky")); diff --git a/practice/practice3.ts b/practice/practice3.ts new file mode 100644 index 0000000..eff7dd2 --- /dev/null +++ b/practice/practice3.ts @@ -0,0 +1,9 @@ +const states = [ + { name: "Alabama", capital: "Montgomery" }, + { name: "Alaska", capital: "Juneau" }, + { name: "Arizona", capital: "Phoenix" }, +]; + +for (const state of states) { + console.log(state.capitol); +}