실전 패턴 — Result·Branded·exhaustive
시리즈 마지막. 코드를 안전하게 만드는 4가지 도구.
20편의 마지막. 1~19편의 도구를 합쳐 만드는 실전 코드의 안전 장치 4가지 — Result 타입, Branded 타입, exhaustive switch, type-safe builder. 진짜 코드에 바로 적용 가능한 패턴들입니다.
① Result<T, E> — try/catch 대안
// 함수가 "성공 또는 실패" 를 명시적으로
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// 헬퍼
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
// 사용
function parseInt2(s: string): Result<number, string> {
const n = Number(s);
if (Number.isNaN(n)) return err(`"${s}" 는 숫자가 아님`);
return ok(n);
}
const r = parseInt2(input);
if (r.ok) {
console.log(r.value + 1); // r.value: number
} else {
console.error(r.error); // r.error: string
}
왜 Result? try/catch 는 "어떤 에러가 날 수 있는지" 타입에 안 보임. Result 는 시그니처에 실패 가능성을 명시 → 호출자가 처리를 잊을 수 없음. Rust·OCaml 의 영향.
② Branded 타입 — 같은 string 인데 의미 분리
// 일반 string 끼리 섞이는 함정
function deleteUser(userId: string) { ... }
function getOrder(orderId: string) { ... }
deleteUser(orderId); // ❌ 의도 안 됐는데 컴파일 통과 (둘 다 string)
// Branded — 같은 underlying 타입에 "마크" 붙이기
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function UserId(s: string): UserId { return s as UserId; }
function OrderId(s: string): OrderId { return s as OrderId; }
function deleteUser(id: UserId) { ... }
function getOrder(id: OrderId) { ... }
const u = UserId("u_1");
const o = OrderId("o_2");
deleteUser(u); // OK
deleteUser(o); // ❌ OrderId 는 UserId 가 아님
// 더 안전 — 검증과 함께
function UserId(s: string): UserId {
if (!/^u_\d+$/.test(s)) throw new Error("invalid UserId");
return s as UserId;
}
Branded 의 진가. 같은 string·number 인데 의미가 다른 값들이 섞이는 버그를 컴파일 시점에 차단. 결제 금액(Cents) vs 상품 ID(ProductId) 등. 런타임 코드 0.
③ exhaustive check — switch 의 빠진 case 잡기
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "rect": return s.w * s.h;
default:
const _exhaustive: never = s; // ← 새 case 추가 시 컴파일 에러
throw new Error(`Unhandled: ${(s as any).kind}`);
}
}
// 나중에 Shape 에 triangle 추가하면
type Shape = ... | { kind: "triangle"; base: number; height: number };
// area 의 default 에서 _exhaustive 가 'triangle' 받게 되어
// 'triangle' is not assignable to 'never' → 컴파일 에러
// → 빠진 case 즉시 발견
④ type-safe builder — 단계별 검증
// 필수 필드를 빠뜨리면 컴파일 에러
type EmailDraft = { to: string; subject: string; body: string };
class EmailBuilder<T = {}> {
constructor(private state: T = {} as T) {}
to(addr: string): EmailBuilder<T & { to: string }> {
return new EmailBuilder({ ...this.state, to: addr });
}
subject(s: string): EmailBuilder<T & { subject: string }> {
return new EmailBuilder({ ...this.state, subject: s });
}
body(b: string): EmailBuilder<T & { body: string }> {
return new EmailBuilder({ ...this.state, body: b });
}
send(this: EmailBuilder<EmailDraft>): void {
// 여기 도달했다면 T 가 EmailDraft 와 호환 = 모든 필드 있음
sendEmail(this.state);
}
}
new EmailBuilder()
.to("[email protected]")
.subject("Hi")
.body("...")
.send(); // OK
new EmailBuilder()
.to("[email protected]")
.send(); // ❌ subject·body 없음 → 컴파일 에러
⑤ const assertions — readonly literal 타입
// 그냥 객체 — 타입이 너무 넓음
const config = { url: "https://api", timeout: 5000 };
// 타입: { url: string; timeout: number }
// as const — 정확한 literal
const config = { url: "https://api", timeout: 5000 } as const;
// 타입: { readonly url: "https://api"; readonly timeout: 5000 }
// 배열에도
const ROLES = ["admin", "user", "guest"] as const;
type Role = typeof ROLES[number]; // "admin" | "user" | "guest"
// 11편에서 이걸로 enum 대체
⑥ satisfies — 타입은 좁게, 값은 그대로
type Config = Record<string, string | number>;
// as Config — 좁히기 위반
const cfg: Config = { port: 3000, host: "x.com" };
cfg.port.toFixed(2); // ❌ port: string | number
// satisfies — 검사하되 타입은 좁게 유지
const cfg = { port: 3000, host: "x.com" } satisfies Config;
cfg.port.toFixed(2); // ✅ port: number 그대로 유지
cfg.host.toUpperCase(); // ✅ host: string
⑦ 함수 시그니처 한 줄로 타입 추출
function createUser(name: string, age: number) {
return { id: 1, name, age, createdAt: new Date() };
}
// 일일이 적지 말고
type User = ReturnType<typeof createUser>; // { id; name; age; createdAt }
type Args = Parameters<typeof createUser>; // [string, number]
📚 20편 시리즈 회고 — 무엇을 익혔나
| 파트 | 핵심 |
|---|---|
| Part 1 입문(1-4) | 설치·기본 타입·함수·interface vs type |
| Part 2 기초(5-10) | 유니온·제네릭·enum·클래스·모듈·tsconfig |
| Part 3 중급(11-15) | 유틸리티·조건부·매핑·타입 가드·비동기 |
| Part 4 고급(16-20) | 데코레이터·.d.ts·strict·빌드·실전 패턴 |
다음은? 실제 프로젝트에 적용해 보세요. ① Next.js·SvelteKit·Remix 같은 프레임워크로 풀스택 앱. ② tRPC·Hono 같은 타입 안전 API 라이브러리. ③ Effect·neverthrow 같은 함수형 라이브러리 — Result 패턴을 본격적으로. ④ ts-pattern — exhaustive 매칭의 차세대.
마지막 한 줄
"타입은 미래의 자신과 동료를 위한 메모"입니다. 처음에는 귀찮지만 6개월 뒤 코드 다시 볼 때 빛납니다. 새 코드에 strict 가 켜져 있다면 절반은 이미 성공.
🎓 시리즈 완결
쉽게 배우는 타입스크립트 20편 시리즈 끝. 처음 1편부터 다시 보면 두 번째는 훨씬 명료할 거예요. 다음 단계는 실제 프로젝트.
이전: 19편 빌드 성능 · 현재: 20편 (졸업작 ★) · 진행: 20/20 ✅