💡 Tips

TypeScriptのtry/catchをResult型へ|neverthrow段階的リファクタ実装パターン【個人開発】

結論:全部をResult型にせず「境界」だけ置き換える

TypeScriptでtry/catchが型安全でないのは、catch (e)のeがunknown(旧設定ではany)でしかなく、どの関数がどんな失敗をするかが型に現れないからだ。呼び出し側はドキュメントか実装を読むまで失敗の可能性に気づけない。

これを直すのに、コードベース全体を書き換える必要はない。効くのは次の3箇所だけだ。

  1. 外部I/Oの境界(fetch・DBクライアント・ファイル・外部SDK)
  2. 入力バリデーション(リクエストボディ、環境変数)
  3. 上の2つを合成するユースケース層

この境界をneverthrowのResult/ResultAsyncで包み、UIやルートハンドラの最外周でmatchして潰す。以下、段階的な手順と実コードを示す。

try/catchとResult型の比較

観点try/catchResult型(neverthrow)
失敗の可視性型に出ない戻り値の型に出る
エラーの型unknown自分で定義した判別可能ユニオン
網羅チェックされないswitch+neverで可能
合成ネストしがちandThen/mapで連結
未処理の検出不可Lintルールが必要(型だけでは防げない)
学習コストゼロ低〜中(チームでは合意が要る)

「型に出る」ことが唯一にして最大の利点だと考えてよい。逆に言えば、エラー型を雑にErrorのままにするとメリットの大半が消える。

手順1:エラー型を先に定義する

リファクタはコードからではなく型から始める。レイヤーごとに、そのレイヤーが返しうる失敗を判別可能ユニオンで列挙する。

// src/errors.ts
export type ApiError =
  | { type: 'network'; cause: unknown }
  | { type: 'http'; status: number }
  | { type: 'parse'; cause: unknown };

export type DbError =
  | { type: 'unique_violation'; column: string }
  | { type: 'not_found' }
  | { type: 'db_unknown'; cause: unknown };

ポイントは、プログラマのバグ(nullアクセス、実装ミス)はここに入れないこと。それは投げっぱなしにしてクラッシュさせたほうが早く直る。Result型に載せるのは「起きて当然の失敗」だけだ。

手順2:APIレイヤーをResultAsync.fromPromiseで包む

fetchは404で例外を投げず、ネットワーク断でのみ投げる。この非対称さがバグの温床になるので、まとめてResultに寄せる。

import { ResultAsync, ok, err, type Result } from 'neverthrow';
import { userSchema, type User } from './schema';

export function fetchUser(id: string): ResultAsync<User, ApiError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`),
    (cause): ApiError => ({ type: 'network', cause }),
  )
    .andThen((res): Result<Response, ApiError> =>
      res.ok ? ok(res) : err({ type: 'http', status: res.status }),
    )
    .andThen((res) =>
      ResultAsync.fromPromise(
        res.json(),
        (cause): ApiError => ({ type: 'parse', cause }),
      ),
    )
    .andThen((json): Result<User, ApiError> => {
      const parsed = userSchema.safeParse(json);
      return parsed.success
        ? ok(parsed.data)
        : err({ type: 'parse', cause: parsed.error });
    });
}

andThenの引数に戻り値型を明示しておくと、ユニオンの推論がぶれずに済む。ここが実装時に一番つまずく箇所だ。

手順3:DBレイヤーはドライバのエラーコードを型に翻訳する

DB層の価値は「PostgreSQLの23505」のような生の情報を、上位が判断できる語彙に変換する点にある。

import { ResultAsync } from 'neverthrow';

function toDbError(cause: unknown): DbError {
  if (typeof cause === 'object' && cause !== null && 'code' in cause) {
    if ((cause as { code: string }).code === '23505') {
      return { type: 'unique_violation', column: 'email' };
    }
  }
  return { type: 'db_unknown', cause };
}

export const createUser = (input: NewUser): ResultAsync<User, DbError> =>
  ResultAsync.fromPromise(
    db.insert(users).values(input).returning(),
    toDbError,
  ).map((rows) => rows[0]);

既存の同期関数を包みたいときはResult.fromThrowableが使える。JSON.parseのような小物はこれで十分だ。

手順4:最外周でmatchして必ず消費する

Resultは最後まで持ち回らず、HTTPレスポンスやUI状態に落とすところで畳む。

app.post('/users', async (c) => {
  const result = await parseBody(c).asyncAndThen(createUser);

  return result.match(
    (user) => c.json(user, 201),
    (e) => {
      switch (e.type) {
        case 'validation':
          return c.json({ message: e.detail }, 400);
        case 'unique_violation':
          return c.json({ message: 'すでに登録済み' }, 409);
        case 'not_found':
          return c.json({ message: '見つからない' }, 404);
        default:
          console.error(e);
          return c.json({ message: 'サーバーエラー' }, 500);
      }
    },
  );
});

エラー種別を増やしたときにswitchが漏れたら型エラーにしたい場合は、defaultの代わりにconst _: never = e;を置く。これがtry/catchでは得られない実利だ。

同じ設計は非同期ワーカーでも効く。キュー処理でのリトライ判定は「どのエラー型なら再試行するか」を型で分岐できるため、Cloudflare Queues × Workers TypeScriptの最小ジョブキューのような構成と相性がよい。

正直に書いておく注意点

  • ボイラープレートは確実に増える。3行で済んだ処理が10行になる場面があり、UIの一時的な状態管理まで全部Result化すると読みづらくなる。境界に留めるべき理由がこれだ。
  • 未処理のResultは型では防げない。戻り値を捨ててもResultは例外を投げないため、握り潰しに気づけない。ESLintのno-floating-promises相当のルールやneverthrow向けプラグインの導入を検討したいが、対応状況はバージョンによって変わるので公式リポジトリの最新情報を確認してほしい。
  • ライブラリ境界には例外が残る。サードパーティSDKは例外を投げてくるので、fromPromiseで包む層を必ず1枚挟む。ここを飛ばすと「Result型なのに例外が飛ぶ」最悪の状態になる。
  • _unsafeUnwrapは移行期でも使わない。名前のとおり例外を投げるため、型安全を取り戻す目的と矛盾する。テストコード以外では禁止にしておくのが無難だ。
  • チーム開発では合意が要る。全員がAPIを把握していない状態で入れると、レビューコストが増えるだけになりやすい。裁量が全部自分にある個人開発が導入の最適地だと考えている。

型システムの前提知識が曖昧なまま入れるとandThenの推論エラーで消耗する。判別可能ユニオンやunknownの扱いに不安があるなら、先に基礎を固めたほうが結果的に速い。

プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで Software Design plus
📚 おすすめ書籍

プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで Software Design plus

参考価格¥3,212(税込)

型の基礎からunknownの扱いまで一冊で整理できる

Amazonで最新価格をチェック

まとめ

段階的リファクタの順番は「エラー型の定義 → I/O境界のラップ → 合成 → 最外周でmatch」の4ステップだ。1機能ぶんだけこの流れを通してみて、switchの網羅チェックが効く感触が得られたら横展開すればよい。効果が薄いと感じたら境界の外へは広げない、という引き際も含めて判断したい。