🐹

neverthrowで実践する型安全エラーハンドリング

に公開

はじめに

satto workspaceのプロダクトエンジニアをしている 滝口(@s_takiguchi)です。

早速ですが、TypeScriptでエラーハンドリングを行う際、try-catchを使うことが一般的である一方、これには以下のような課題があります。

  • エラーの型が不明確で、どんなエラーが発生するか関数シグネチャから読み取れない
  • エラーハンドリングの漏れが実行時まで発見できない
  • エラーの伝播が暗黙的で、コードの流れを追いにくい

個人的には課題とまで感じることは少なかったですが、「発生しうるエラーの種類が分かったら楽しそう」という気持ちでResult型を導入したらとても快適でした。

そこで、関数型プログラミングのResult型パターンを実現できるライブラリの1つであるneverthrowを使用したエラーハンドリングの例を共有していきます。

neverthrow選定理由

https://github.com/supermacro/neverthrow

TypeScript の関数型ライブラリには複数の選択肢がありますが、以下の比較表で neverthrow を選んだ理由を説明します。

TypeScript Result型ライブラリ比較

ライブラリ 学習コスト サイズ 特徴
neverthrow 小(~2KB) 専用設計、シンプルなAPI
fp-ts 大(~100KB+) 理論的に正確、豊富な関数
Effect エフェクトシステム、非同期対応
True Myth 小(~5KB) 軽量、Maybe型も提供
Sanctuary 中(~30KB) Hindley-Milner型

neverthrow を選んだ理由

  1. 学習コストの低さ: 既存のコードベースに段階的に導入可能
  2. エラーハンドリングに特化: 目的が明確で、用途が限定的
  3. 軽量: バンドルサイズへの影響が最小限
  4. チェーン可能な操作: mapandThenmatchなどのメソッドチェーン

関数型の思想導入は"お試し感"が強かったので、学習コストとプロダクトへの導入コストを最優先にしました。

neverthrow 基本知識

かなり雑ですが、ざっくり説明です。

// T: 成功時の返却値
// E: エラー時の返却値
type Result<T, E> 

// 非同期処理バージョン
type ResultAsync<T, E> 

// 例えばこんな関数たちがあったとして
type GetUserFunction = () => ResultAsync<User, CustomError1>
type ValidateUser = (user: User) => Result<User, CustomError2>

const _ = (getUser: GetUserFunction, validateUser: ValidateUser) => {
  return getUser()
    // andThen で正常終了の時の値を受け取って次の処理を書ける
    .andThen((user)=> validateUser(user))
    // 一連の処理の最後に match を使用して 成功失敗の処理を分岐
    .match(
      (user) => console.log(user)  // User
      (error) => console.error(error) // CustomError1 | CustomError2
    )
}

実装例

では実際にコードを交えて見ていきましょう。
ここでは「管理者ユーザーを取得するAPI」という例で説明していきます。

カスタムエラー定義

アプリケーション全体で使用するエラーインターフェースの定義です。
後続で使用するエラーを定義します。

// custom-errors.ts
type ICustomError = {
  errorId: string;
  message: string;
};

export const UserNotAdminError = {
  errorId: "USER_NOT_ADMIN_ERROR",
  message: "User is not admin",
} as const satisfies ICustomError;

export const UserFetchError = {
  errorId: "USER_FETCH_ERROR",
  message: "User not found",
} as const satisfies ICustomError;

export const ValidateIdError = {
  errorId: "VALIDATE_ID_ERROR",
  message: "ID is required",
} as const satisfies ICustomError;
ユーザーの定義

ユーザーオブジェクトと、管理者であるかをチェックする関数の定義です。

// user-domain.ts
import { err, ok, type Result } from "neverthrow";
import { UserNotAdminError } from "./custom-errors";

export type User = {
  id: string;
  name: string;
  email: string;
  role: "ADMIN" | "USER";
};

export const checkAdmin = (
  user: User,
): Result<User, typeof UserNotAdminError> => {
  return user.role === "ADMIN" ? ok(user) : err(UserNotAdminError);
};
データの取得関数

この記事では実装への関心を持たないようにするため、インターフェースとして扱います。

// I-user-repository.ts
import type { ResultAsync } from "neverthrow";
import type { UserFetchError } from "./custom-errors";
import type { User } from "./user-domain";

export interface IUserRepository {
  findById(id: string): ResultAsync<User, typeof UserFetchError>;
}

API 実装

上記定義したものを組み合わせて実装してみます。

1. シンプルな処理

先ほどのneverthrow基本知識でお見せした部分と重なるところもありますが、
まずは単一処理にてどのような感じで書けるのかを見ていきましょう。

import type { IUserRepository } from "./I-user-repository";

export const getUserAPIRoute = async (id: string, userRepository: IUserRepository) => {
  return await userRepository.findById(id).match(
    (user) => user,     // 成功時: ユーザーオブジェクトを返す
    (error) => error,   // 失敗時: エラーオブジェクトを返す
  );
};

エラーの型はこんな感じで見られる。
エラーの型推論(UserFetchError)

try catch であれば unknown になるはずの error に型があることを確認できました。

2. 複数のエラーが考えられる処理

続いて処理が連続する場合です。
より実践的になります。

import { err, ok, type Result } from "neverthrow";
import { ValidateIdError } from "./custom-errors";
import type { IUserRepository } from "./I-user-repository";
import { checkAdmin } from "./user-domain";

const validateId = (id: string): Result<string, typeof ValidateIdError> => {
  return id !== "" ? ok(id) : err(ValidateIdError);
};

export const getUserAPIRoute2 = async (id: string, userRepository: IUserRepository) => {
  return await validateId(id) // ValidateIdErrorの可能性
    .asyncAndThen((id) => userRepository.findById(id)) // UserFetchErrorの可能性
    .andThen((user) => checkAdmin(user)) // UserNotAdminErrorの可能性
    .match(
      (user) => user,
      (error) => error,
    );
};

エラーの型はこんな感じで見られる。
複数エラーのユニオン型推論

3.関数合成とデバッグ

先ほどの例を簡潔にし、メソッドチェインをより増やしてみた例です。
例えば.andThen((user) => checkAdmin(user)).andThen(checkAdmin)へ省略しているところに注目して見てください。

また、デバッグログも1行で差し込み可能です。

export const getUserAPIRoute3 = async (id: string, userRepository: IUserRepository) => {
  return await validateId(id)
    .asyncAndThen(userRepository.findById)
    .andThen(checkAdmin)
    .andTee(console.log)                 // デバッグ: userオブジェクト
    .map((user) => user.id)              // データ変換
    .andTee(console.log)                 // デバッグ: userId
    .match(
      (userId) => userId,
      (error) => error,
    );
};

4. エラーハンドリング

最後にエラーハンドリングの例です。
エラー型がわかることで型安全にハンドリングができます。

export const getUserAPIRoute4 = async (id: string, repository: IUserRepository) => {
  return await validateId(id)
    .asyncAndThen(repository.findById)
    .andThen(checkAdmin)
    .match(
      (user) => user,
      (error) => {
        switch (error.errorId) {
          // 型安全にエラーハンドリングできる
          case "VALIDATE_ID_ERROR":
            return "ID検証のエラー";
          case "USER_FETCH_ERROR":
            return "ユーザー取得のエラー";
          case "USER_NOT_ADMIN_ERROR":
            return "アドミンでないエラー";
          default:
            return "到達しない処理";
        };
      },
    );
};

まとめ

neverthrowを使った関数型エラーハンドリングにより:

  1. エラーの型が明確になる
  2. エラーハンドリングの漏れを防げる
  3. つまり気分が上がる

おわりに

軽い気持ちで導入したResult型ですが、error の型が unknown だとモヤモヤするくらいハマってしまいました。
エラーハンドリングだけでなく、メソッドチェインでスマートに記述できるのが個人的に刺さっています。

余談ですが、neverthrow like なものでbyethrowというライブラリも気になっています。
当時は認知しておらず、検討候補にはなかったですが、これからneverthrowを導入検討する方は合わせて比較してみると面白いでしょう。

参考リンク

ソフトバンク株式会社_satto開発チーム

Discussion