TypeScriptでResultパターンを実装する:try-catchを使わないプロフェッショナルなエラーハンドリング

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

コンテキストと理由:try-catchの隠れたコスト

TypeScriptを使い始めた頃、表面上はきれいに見えても、実際にはいつ爆発してもおかしくない地雷のような関数を書いていました。たとえばこんな感じです:

async function fetchUserProfile(userId: string) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    const data = await res.json();
    return data;
  } catch (err) {
    console.error(err);
    return null;
  }
}

関数のシグネチャはユーザーデータを返すと示しています。しかし実際には暗黙のうちにnullを返すことがあります。呼び出し側は失敗の可能性を把握できません——午前2時に本番環境で何かが爆発するまでは。

これがTypeScriptにおけるtry-catchの核心的な問題です:エラーが型システムから見えないのです。TypeScriptはコードについて多くのことを教えてくれますが、失敗ケースの処理を忘れていても警告してくれません。なぜなら失敗が関数の戻り値型に現れないからです。

Resultパターンはエラーを戻り値型に直接エンコードすることでこの問題を解決します。例外をスローしたりnullを返したりする代わりに、関数は成功値か型付きエラーのどちらかを返します——そして型システムが呼び出し側に両方の処理を強制します。もう忘れることはありません。

実践では、これはすぐに効果を発揮します。コードが500〜1000行程度になると、チームはどの関数が暗黙的に失敗しうるかを把握できなくなり始めます。Resultパターンはその知識を見逃しようのないものにします——型に書いてあるのですから。

Resultパターンの見た目

最もシンプルな形では、このパターンはただの判別共用体(discriminated union)です:

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

失敗しうる関数はUser | nullの代わりにResult<User, ApiError>を返します。これによりTypeScriptは失敗の可能性を認識します。okを先に確認しなければ値を使えないようになります。

インストール:アプローチの選択

選択肢は2つあります:軽量なResultタイプを自作するか、既存ライブラリを利用するかです。どちらも機能します——最初から何が必要かに応じて選んでください。

オプションA:自作する(学習に推奨)

プロジェクトがなければ、新しいTypeScriptプロジェクトを作成します:

mkdir result-pattern-demo
cd result-pattern-demo
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init

次にsrc/result.tsファイルを作成し、コアとなる型とヘルパー関数を定義します:

export type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

2つのヘルパー関数——ok()err()——があれば、どんな関数でもResultを返し始めることができます。

オプションB:neverthrowを使う

チェーン、非同期サポート、その他のユーティリティをすぐに使いたいですか?neverthrowはTypeScriptエコシステムで最も広く使われているResultライブラリです:

npm install neverthrow

同じok()err()パターンに加え、.map().mapErr()、Promiseベースのワークフロー向けのResultAsyncなどの便利機能も提供します。このチュートリアルでは、自作バージョンでコアとなるアイデアを理解するのに十分です。

設定:実際のコードにパターンを組み込む

いよいよ面白い部分です——例外をスローする代わりにResultを使うようにサービス層を書き直します。

ステップ1:エラータイプを定義する

このパターンの最大のメリットの一つは型付きエラーです。未知のErrorをキャッチする代わりに、何が失敗しうるかを正確に定義します:

// src/errors.ts
export type ApiError =
  | { type: 'NOT_FOUND';    message: string }
  | { type: 'UNAUTHORIZED'; message: string }
  | { type: 'NETWORK_ERROR'; message: string; statusCode?: number };

すべての呼び出し側が、処理すべきエラーケースを事前に把握できるようになります。もう推測は不要です。

ステップ2:サービス関数を書き直す

// src/userService.ts
import { Result, ok, err } from './result';
import { ApiError } from './errors';

interface User {
  id: string;
  name: string;
  email: string;
}

async function fetchUser(userId: string): Promise<Result<User, ApiError>> {
  try {
    const res = await fetch(`https://api.example.com/users/${userId}`);

    if (res.status === 404) {
      return err({ type: 'NOT_FOUND', message: `ユーザー ${userId} が見つかりません` });
    }
    if (res.status === 401) {
      return err({ type: 'UNAUTHORIZED', message: '無効または期限切れのトークン' });
    }
    if (!res.ok) {
      return err({
        type: 'NETWORK_ERROR',
        message: '予期しないAPIエラー',
        statusCode: res.status,
      });
    }

    const user: User = await res.json();
    return ok(user);
  } catch (e) {
    return err({ type: 'NETWORK_ERROR', message: 'APIに接続できませんでした' });
  }
}

try-catchはここにまだ存在しています——しかし予測不能な処理が実際に行われる境界、つまりネットワーク呼び出しの部分に限定されています。アプリケーションの残りの部分ではtry-catchは二度と必要ありません。

ステップ3:呼び出し元でResultを処理する

ここでピンとくるはずです。呼び出し元はクリーンで予測可能なインターフェースを得ます:

// src/index.ts
import { fetchUser } from './userService';

async function main() {
  const result = await fetchUser('user-123');

  if (!result.ok) {
    // TypeScriptはここでresult.errorをApiErrorに絞り込みます
    switch (result.error.type) {
      case 'NOT_FOUND':
        console.log('404ページを表示:', result.error.message);
        break;
      case 'UNAUTHORIZED':
        console.log('ログインにリダイレクト:', result.error.message);
        break;
      case 'NETWORK_ERROR':
        console.log(`後でリトライしてください。ステータス: ${result.error.statusCode}`);
        break;
    }
    return;
  }

  // TypeScriptはここでresult.valueがUserであることを認識しています——キャストは不要です
  console.log(`ようこそ、${result.value.name}さん!`);
}

main();

後からApiErrorに新しいエラータイプを追加すると、TypeScriptはそれを処理し忘れたすべてのswitch文をハイライトします。これはtry-catchでは絶対に得られないコンパイル時の安全性です。

ステップ4:レイヤー間でResultを合成する

ユーザーを取得し、次にその注文を取得するとします。どちらも失敗しうる処理です。Resultを使えば、チェーンするのは簡単です——ネストされたtry-catchブロックは不要です:

async function getUserWithOrders(userId: string) {
  const userResult = await fetchUser(userId);
  if (!userResult.ok) return userResult; // エラーを上位に伝播させる

  const ordersResult = await fetchOrders(userResult.value.id);
  if (!ordersResult.ok) return ordersResult;

  return ok({
    user: userResult.value,
    orders: ordersResult.value,
  });
}

各ステップは前のステップが成功した場合のみ続行します。何かが失敗した場合、エラーは型付きの値としてバブルアップします——コールスタックのどこかで握り潰されるかもしれない例外としてではなく。

検証とモニタリング:正しく動作することを確認する

クイックスモークテストを実行する

ts-nodeでエントリーポイントを実行し、正常系と異常系の両方が正しく動作することを確認します:

# スクリプトを実行する
npx ts-node src/index.ts

# 期待される出力(正常系):
# ようこそ、Jane Doeさん!

# 期待される出力(見つからない場合):
# 404ページを表示:ユーザー user-999 が見つかりません

TypeScriptが未処理ケースを検出することを確認する

switch文を更新せずにApiErrorに新しいエラータイプを追加してみてください:

export type ApiError =
  | { type: 'NOT_FOUND';    message: string }
  | { type: 'UNAUTHORIZED'; message: string }
  | { type: 'NETWORK_ERROR'; message: string; statusCode?: number }
  | { type: 'RATE_LIMITED'; message: string; retryAfter: number }; // 新規追加

TypeScriptコンパイラを実行します:

npx tsc --noEmit

switch文に網羅性チェックを追加すると(以下に示す)、コンパイラは即座に未処理ケースを指摘します:

function assertNever(x: never): never {
  throw new Error('未処理ケース: ' + JSON.stringify(x));
}

// switchのdefaultブロック内:
default:
  assertNever(result.error); // ケースが欠けているとコンパイルエラーになります

コンパイラが網羅性チェッカーになります——コードがリリースされる前に毎回、レビュアーが見つける必要なく、未処理ケースを指摘してくれます。

ログとオブザーバビリティ

エラーは今や戻り値フローの中の単純なオブジェクトです。構造化ログの追加は簡単です:

const result = await fetchUser(userId);

if (!result.ok) {
  // 構造化データとしてログ出力——Datadog、Sentryなどへの送信も容易
  console.error(JSON.stringify({
    event: 'fetch_user_failed',
    errorType: result.error.type,
    userId,
    timestamp: new Date().toISOString(),
  }));
}

未知のErrorオブジェクトをキャッチしてログシステム向けにシリアライズしようとする場合と比べてみてください。型付きエラーはオブザーバビリティを大幅にクリーンにします。

注意すべき点

Resultパターンはすべての場所でtry-catchを置き換えるわけではありません——制御されていない伝播を置き換えるのです。最外部の境界、つまりネットワーク呼び出し、データベースクエリ、ファイル読み込みの部分では依然としてtry-catchが必要です。そこでキャッチし、例外をResultに変換し、残りのアプリケーション全体でResultがクリーンに流れるようにします。

良い経験則として:外部I/Oに触れる関数はtry-catchで包んでResultを返す。自分の関数のみを呼び出す場合はResultを伝播させる——try-catchは不要です。

一貫して使用すれば、Resultパターンはコードを読みやすく、テストしやすく、そして暗黙的に壊れにくくします。型システムは、コードレビューが見逃しがちな規律を強制します。

Share: