C
TypeScript/비동기/Lesson 07

에러 처리 심화 — Discriminated Union + Result<T, E> 패턴

40분·theory
이 챕터
7/7
TypeScript

에러 처리 심화 — Discriminated Union + Result 패턴

💡 왜 배워야 할까요? — 에러를 '값' 으로 다루면 더 안전하다

🎯 throw/catch 는 흐름이 점프합니다 — 호출자가 어떤 에러를 잡아야 할지 시그니처만 봐선 모릅니다.
💼 Result 패턴은 에러를 **반환값** 으로 다룹니다. 함수 시그니처에 가능한 에러 종류가 박혀 있어 누락이 컴파일 시점에 드러납니다.
Discriminated Union(`type Result = Ok | Err`) 으로 모델링하면 호출부가 `if (result.ok)` 같이 자연스럽게 분기.
🔗 Rust·Go·Kotlin Result/Either 패턴과 같은 사고방식 — 함수형 프로그래밍의 표준 도구.
📈 `never` 타입으로 'exhaustive check'(모든 경우 처리했는지) 를 컴파일러가 검증해줍니다.
🏢 실무에서는
Next.js Server Action 의 반환 타입을 `Promise>` 같이 박으면, 클라이언트 컴포넌트가 성공/실패 분기를 빠짐없이 처리하게 됩니다. throw 로 에러가 흐르면 클라이언트는 'try/catch 를 어디서 감쌌어야 하지' 같은 고민을 해야 하지만, Result 로 반환하면 컴파일러가 그 고민을 대신해줍니다.

Discriminated Union · Result · Exhaustive Check

1. Discriminated Union — 태그 필드로 분기되는 타입

ts
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  if (s.kind === 'circle') return Math.PI * s.radius ** 2; // s 는 circle 분기로 좁혀짐
  if (s.kind === 'square') return s.side ** 2;
  // 여기에 도달하면 TS 가 의심함 (다음 항목)
}

태그(kind) 필드 값을 좁히면 객체 모양 전체가 좁혀집니다.

2. Result 패턴

ts
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

function parsePositive(s: string): Result<number, string> {
  const n = Number(s);
  if (Number.isNaN(n)) return { ok: false, error: '숫자가 아님' };
  if (n < 0) return { ok: false, error: '음수 안 됨' };
  return { ok: true, value: n };
}

const r = parsePositive('-3');
if (r.ok) console.log(r.value);  // r 은 Ok<number> 로 좁혀짐
else console.log(r.error);       // r 은 Err<string> 로 좁혀짐

호출부에서 에러 처리를 잊으면 컴파일이 막아준다r.value 를 if 없이 바로 쓰면 거부.

3. never 로 Exhaustive Check (망라 확인)

ts
type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keypress'; key: string }
  | { kind: 'scroll'; offset: number };

function handle(e: Event): string {
  switch (e.kind) {
    case 'click': return `클릭 (${e.x},${e.y})`;
    case 'keypress': return `키: ${e.key}`;
    case 'scroll': return `스크롤: ${e.offset}`;
    default:
      const _exhaustive: never = e; // 새 종류 추가하면 여기서 컴파일 에러
      return _exhaustive;
  }
}

Event 에 새 종류를 추가했는데 switch 에 안 적었으면, enever 가 아니라서 _exhaustive: never 할당이 컴파일 거부 → 처리 누락을 컴파일 시점에 발견.

4. throw vs Result — 언제 무엇을?

상황throwResult
절대 일어나면 안 되는 버그
예상 가능한 사용자 입력 오류
외부 API 응답 처리
깊이 중첩된 호출의 비상 탈출

throw 는 '비정상' 용, Result 는 '예상되는 실패' 용.

💻 🅰️ JS 방식 — throw/catch 만으로 에러 분기
// ❌ JS — throw 의 종류가 시그니처에 안 박힘

function parsePositive(s) {
  const n = Number(s);
  if (Number.isNaN(n)) throw new Error('숫자가 아님');
  if (n < 0) throw new Error('음수 안 됨');
  return n;
}

// 호출자는 함수 본문을 봐야 어떤 에러가 가능한지 앎
function main() {
  try {
    const n = parsePositive('-3');
    console.log('성공:', n);
  } catch (err) {
    console.log('실패:', err.message);
    // 에러 종류를 구분하려면 err.message 문자열 비교? — 깨지기 쉬움
    if (err.message === '숫자가 아님') console.log('숫자 입력 안내');
    else if (err.message === '음수 안 됨') console.log('양수 입력 안내');
  }
}
main();

// 사용자가 try/catch 를 깜빡하면? — 런타임에 폭발
💻 🅱️ TS 방식 — Result + Discriminated Union + exhaustive check
// ✅ TS — Result 패턴으로 에러를 값으로

// 1. Result 정의
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });

// 2. 에러 종류를 Discriminated Union 으로
type ParseError =
  | { kind: 'notNumber'; input: string }
  | { kind: 'negative'; value: number };

function parsePositive(s: string): Result<number, ParseError> {
  const n = Number(s);
  if (Number.isNaN(n)) return err({ kind: 'notNumber', input: s });
  if (n < 0) return err({ kind: 'negative', value: n });
  return ok(n);
}

// 3. 호출부 — exhaustive check
function handle(input: string): string {
  const r = parsePositive(input);

  if (r.ok) {
    return `성공: ${r.value}`; // r 은 Ok<number> 로 좁혀짐
  }

  // r 은 여기서 Err<ParseError> 로 좁혀짐
  switch (r.error.kind) {
    case 'notNumber':
      return `숫자가 아님: "${r.error.input}"`;
    case 'negative':
      return `음수: ${r.error.value} — 양수만`;
    default: {
      const _exhaustive: never = r.error;
      return _exhaustive;
    }
  }
}

console.log(handle('42'));   // 성공: 42
console.log(handle('-3'));   // 음수: -3 — 양수만
console.log(handle('abc'));  // 숫자가 아님: "abc"

// 만약 ParseError 에 새 종류 추가하면
// → switch 에 분기 추가 안 하면 컴파일 에러 (never 에 할당 불가)
// → 처리 누락을 컴파일 시점에 발견

💡 💡 Result 패턴 실전 가이드

1. Result 는 라이브러리 없이 만든다 — 30줄이면 끝

ts
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok  = <T>(v: T): Result<T, never> => ({ ok: true, value: v });
const err = <E>(e: E): Result<never, E> => ({ ok: false, error: e });

2. 에러도 Discriminated Union 으로 분류

ts
type FormError =
  | { kind: 'empty'; field: string }
  | { kind: 'tooLong'; field: string; max: number };

에러 종류마다 추가 정보(field·max) 를 정확한 위치에 박는다.

3. never 로 exhaustive check 강제

ts
default: const _: never = errValue; // 처리 누락 시 컴파일 에러

4. Result 와 throw 의 경계
예측 가능한 실패(검증 실패·404·인증) → Result. 예측 불가 버그(null pointer·OOM) → throw + 글로벌 핸들러.

5. async + Result

ts
async function getUser(id: number): Promise<Result<User, ApiError>> {
  try {
    const user = await fetchJson<User>(`/api/users/${id}`);
    return ok(user);
  } catch (e) {
    if (e instanceof ApiError) return err(e);
    throw e; // 예상 못한 에러는 다시 던짐
  }
}

예상된 실패는 Result, 예상 못한 에러는 throw 로 위로.

⚡ 직접 실행해보기 — Result 패턴

에러를 반환값으로 처리하는 흐름을 체감합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

Discriminated Union 에 `default: const _: never = x;` 패턴을 두는 이유는?
💡 switch 의 모든 케이스를 처리하면 `default` 도달 시점에 `x` 의 타입이 `never` 로 좁혀집니다. 만약 Union 에 새 종류를 추가했는데 switch 에 분기를 안 적었다면, `x` 가 `never` 가 아니라 그 새 종류 타입이 되어 `_: never = x` 할당이 컴파일 에러를 냅니다. 처리 누락을 컴파일러가 잡아주는 패턴입니다.
먼저 읽으면 좋은 개념: 에러 처리 — catch 의 unknown 좁히기
다음 추천: React 소개
에러 처리 심화 — Result<T, E> 패턴 - TypeScript