타입스크립트 교재 · 20편 / 20편 ★완결

실전 패턴 — Result·Branded·exhaustive

시리즈 마지막. 코드를 안전하게 만드는 4가지 도구.

고급읽는 시간 8분2026-05-17
Result·Branded·exhaustive 패턴이 실제 코드에서 어떻게 안전을 보장하는지 도식

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 ✅

© 2026 주나이테크(주) @JUNAITECH