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> に絞り込まれる

呼び出し元がエラー処理を忘れるとコンパイラがブロックしますif チェックなしに r.value を直接参照しようとすると拒否されます。

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 に case を書き忘れると、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 ポインタ · 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 で返し、想定外のエラーは再スローします。

⚡ 実際に試してみよう — Result パターン

エラーを戻り値として処理する流れを体感します。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

Discriminated Union の中に `default: const _: never = x;` パターンを置く理由は何ですか?
💡 switch のすべてのケースを処理すると、`default` に到達した時点で `x` の型が `never` に絞り込まれます。Union に新しい種類を追加したにもかかわらず switch に対応する分岐を書かなかった場合、`x` は `never` ではなくその新しい種類の型になり、`_: never = x` の代入でコンパイルエラーが発生します。処理漏れをコンパイラが検出するパターンです。
次のおすすめ: Reactの紹介
エラー処理の深化 — Result<T, E> パターン - TypeScript