【TypeScript】neverthrowで実現する型安全なエラーハンドリング

https://qiita.com/yonaka15/items/276d1ac3e42192037e81

昨日の記事「【TypeScript】try...catchに頼らない型安全なエラーハンドリング」では、TypeScriptでのエラー処理における課題と、その解決策として`effect-ts`と`ts-result`による「Result型パターン」について紹介しました。

今回は、その続編として、Result型パターンを実装したライブラリの一つである「neverthrow」について解説します。

前回紹介した課題をおさらいしておくと：

1. **型の安全性がない**: try...catchでは、エラーの型が`any`または`unknown`になり、どのような種類のエラーが発生するかコンパイル時に把握できない
2. **暗黙的な制御フロー**: 関数のシグネチャからは、その関数が例外をスローする可能性があるかどうかわからない
3. **捕捉すべき場所が不明確**: どこでエラーをキャッチすべきか、コードから読み取りにくい

neverthrowは、これらの問題を解決するためのTypeScriptライブラリで、Rustの`Result`型に着想を得た型安全なエラーハンドリングを提供します。今回はneverthrowの基本的な使い方から実践的なケースまで解説していきます。

## neverthrowとは

neverthrowは、エラーを値として扱い、成功（`Ok`）または失敗（`Err`）のいずれかを表現する`Result`型を中心としたライブラリです。関数が例外をスローする代わりに`Result`を返すことで、型安全性を保ちながらエラーハンドリングを行うことができます。

同期処理には`Result`型、非同期処理には`ResultAsync`型を使用します。`ResultAsync`は`Promise<Result<T, E>>`をラップし、`Result`と同様のAPIを提供します。

## インストール方法

npmまたはyarnを使ってインストールできます：

```bash
npm install neverthrow
# または
yarn add neverthrow
```

## 基本的な使い方

### 1. Result型の作成

最も基本的な使い方は、`ok`関数と`err`関数を使って`Result`型のインスタンスを作成することです：

```typescript
import { ok, err, Result } from 'neverthrow'

// 成功の場合
const successResult: Result<number, string> = ok(42)

// 失敗の場合
const errorResult: Result<number, string> = err("計算エラー")
```

`Result<T, E>`の型パラメータは、`T`が成功時の値の型、`E`が失敗時のエラーの型を表します。

### 2. Resultの状態確認

`Result`が成功か失敗かを確認するには、`isOk()`と`isErr()`メソッドを使います：

```typescript
if (successResult.isOk()) {
  console.log("成功:", successResult.value)
}

if (errorResult.isErr()) {
  console.log("エラー:", errorResult.error)
}
```

### 3. map、mapErrによる変換

値を変換するには`map`と`mapErr`メソッドを使います：

```typescript
// 成功値の変換
const doubledResult = successResult.map(value => value * 2)
// doubledResult は ok(84) となる

// エラーの変換
const betterErrorResult = errorResult.mapErr(error => `${error} (エラーコード: E001)`)
// betterErrorResult は err("計算エラー (エラーコード: E001)") となる
```

### 4. 実用的な例: 数値の除算

次の例では、0による除算を安全に行う関数を実装します：

```typescript
function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return err("ゼロ除算エラー")
  }
  return ok(a / b)
}

// 使用例
const result1 = divide(10, 2)
const result2 = divide(10, 0)

result1.match(
  value => console.log("結果:", value),
  error => console.error("エラー:", error)
)
// 出力: "結果: 5"

result2.match(
  value => console.log("結果:", value),
  error => console.error("エラー:", error)
)
// 出力: "エラー: ゼロ除算エラー"
```

この例では、`divide`関数が0による除算を検出した場合に`err`を返します。呼び出し元では`match`メソッドを使って、成功・失敗それぞれのケースを明示的に処理しています。

## 連鎖的な処理（Railway Oriented Programming）

neverthrowの最も強力な機能の一つが、連鎖的な処理を可能にする`andThen`メソッドです。これは「Railway Oriented Programming」と呼ばれるパターンを実現します。

### andThenによる処理の連鎖

`andThen`は、前の操作が成功した場合にのみ次の操作を実行します。これにより、複数の操作を連鎖させる際に、エラーハンドリングを簡潔に書けます：

```typescript
import { ok, err, Result } from 'neverthrow'

// ユーザー入力を検証する関数
function validateUsername(username: string): Result<string, string> {
  if (username.length < 3) {
    return err("ユーザー名は3文字以上である必要があります")
  }
  return ok(username)
}

// ユーザー名からIDを生成する関数
function generateUserId(username: string): Result<number, string> {
  // もちろん下記のUSER_ID生成は例ですので
  return ok(username.length * 100 + Math.floor(Math.random() * 100))
}

// ユーザー登録を行う関数
function registerUser(username: string): Result<{ id: number, name: string }, string> {
  return validateUsername(username)
    .andThen(validUsername => generateUserId(validUsername)
      .map(userId => ({ id: userId, name: validUsername }))
    )
}

// 使用例
const registrationResult = registerUser("alice")
registrationResult.match(
  user => console.log("登録成功:", user),
  error => console.error("登録失敗:", error)
)
// 出力例: "登録成功: { id: 512, name: 'alice' }"

const failedRegistration = registerUser("al")
failedRegistration.match(
  user => console.log("登録成功:", user),
  error => console.error("登録失敗:", error)
)
// 出力: "登録失敗: ユーザー名は3文字以上である必要があります"
```

この例では、ユーザー登録プロセスを複数のステップに分け、`andThen`で連鎖させています。**最初のステップでエラーが発生すると、後続のステップは実行されず**、エラーが最終結果として返されます。

## 非同期処理での使用（ResultAsync）

現実のアプリケーションでは、APIリクエストやデータベース操作など、非同期処理が不可欠です。neverthrowの`ResultAsync`クラスは、そのような非同期処理をサポートします。

### ResultAsyncの基本

`ResultAsync`は`Promise<Result<T, E>>`のラッパーですが、`Result`と同様のAPIを提供します：

```typescript
import { okAsync, errAsync, ResultAsync } from 'neverthrow'

// 非同期処理の成功
const successAsync = okAsync(42)

// 非同期処理の失敗
const errorAsync = errAsync("非同期処理エラー")

// 使用例
successAsync.match(
  value => console.log("成功:", value),
  error => console.error("エラー:", error)
).then(() => {
  // matchの後はPromiseが返る
})
```

### Promise から ResultAsync への変換

既存のPromiseベースのAPIを使ってResultAsyncを作成するには、`fromPromise`メソッドを使います：

```typescript
import { ResultAsync } from 'neverthrow'

// APIから何かをフェッチする関数
async function fetchData(url: string): Promise<any> {
  const response = await fetch(url)
  return response.json()
}

// ResultAsyncに変換
const result = ResultAsync.fromPromise(
  fetchData('https://api.example.com/data'),
  error => `APIエラー: ${error instanceof Error ? error.message : String(error)}`
)

// 使用
result
  .map(data => {
    console.log("データを受信:", data)
    return data
  })
  .mapErr(error => {
    console.error(error)
    return error
  })
  .then(finalResult => {
    // finalResultはResult<any, string>型
  })
```

### 非同期処理の連鎖

非同期処理も`andThen`で連鎖させることができます：

```typescript
import { ok, err, Result, ResultAsync } from 'neverthrow'

// ユーザーIDからユーザー情報を取得する関数
function fetchUser(id: number): ResultAsync<User, string> {
  return ResultAsync.fromPromise(
    fetch(`https://api.example.com/users/${id}`)
      .then(res => res.json()),
    () => "ユーザー情報の取得に失敗しました"
  )
}

// ユーザーの注文履歴を取得する関数
function fetchOrders(user: User): ResultAsync<Order[], string> {
  return ResultAsync.fromPromise(
    fetch(`https://api.example.com/users/${user.id}/orders`)
      .then(res => res.json()),
    () => "注文履歴の取得に失敗しました"
  )
}

// 使用例: ユーザーIDから注文履歴を取得
const userId = 123
const ordersResult = fetchUser(userId)
  .andThen(user => fetchOrders(user))

ordersResult.match(
  orders => console.log(`${orders.length}件の注文が見つかりました`),
  error => console.error(error)
).then(() => {
  // 処理完了
})
```

## 実践的なパターン

neverthrowをより効果的に使うためのパターンをいくつか紹介します。

### 1. try/catchをResultに変換

既存コードの例外処理をResultパターンに変換するためのヘルパー関数：

```typescript
import { ok, err, Result } from 'neverthrow'

// 例外をResultに変換するヘルパー関数
function tryCatch<T, E = Error>(fn: () => T): Result<T, E> {
  try {
    return ok(fn())
  } catch (error) {
    return err(error as E)
  }
}

// JSONパースでの使用例
function safeJsonParse(json: string): Result<unknown, Error> {
  return tryCatch(() => JSON.parse(json))
}

// 使用例
const validJson = '{"name": "John", "age": 30}'
const invalidJson = '{name: John}'

safeJsonParse(validJson).match(
  data => console.log("パース成功:", data),
  error => console.error("パース失敗:", error.message)
)

safeJsonParse(invalidJson).match(
  data => console.log("パース成功:", data),
  error => console.error("パース失敗:", error.message)
)
```

### 2. カスタムエラー型の使用

より型安全なエラーハンドリングのためにカスタムエラー型を定義できます：

```typescript
// エラー型の定義
type ValidationError = {
  type: 'VALIDATION'
  field: string
  message: string
}

type NetworkError = {
  type: 'NETWORK'
  status: number
  message: string
}

// 使用するエラー型
type AppError = ValidationError | NetworkError

// ユーザー検証関数
function validateUser(user: any): Result<User, ValidationError> {
  if (!user.name) {
    return err({
      type: 'VALIDATION',
      field: 'name',
      message: '名前は必須です'
    })
  }
  
  if (!user.email) {
    return err({
      type: 'VALIDATION',
      field: 'email',
      message: 'メールアドレスは必須です'
    })
  }
  
  return ok(user as User)
}

// APIからユーザーを取得する関数
function fetchUser(id: string): ResultAsync<any, NetworkError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then(res => {
      if (!res.ok) {
        throw {
          type: 'NETWORK',
          status: res.status,
          message: `HTTPエラー: ${res.status}`
        }
      }
      return res.json()
    }),
    (error): NetworkError => {
      if (typeof error === 'object' && error !== null && 'type' in error && error.type === 'NETWORK') {
        return error as NetworkError
      }
      return {
        type: 'NETWORK',
        status: 0,
        message: '未知のネットワークエラー'
      }
    }
  )
}

// エラー型に基づいた処理
fetchUser('123')
  .andThen(user => validateUser(user))
  .match(
    validUser => {
      console.log("有効なユーザー:", validUser)
    },
    error => {
      switch (error.type) {
        case 'VALIDATION':
          console.error(`バリデーションエラー - ${error.field}: ${error.message}`)
          break
        case 'NETWORK':
          console.error(`ネットワークエラー (${error.status}): ${error.message}`)
          break
      }
    }
  )
```

このパターンでは、エラー型にタグを付けることで、発生したエラーの種類を型安全に識別できます。

### 3. フォームバリデーションでの活用

Webフォームの検証は、neverthrowが特に有用なユースケースです：

```typescript
type FormData = {
  username: string
  email: string
  password: string
}

type FormValidationError = {
  field: keyof FormData
  message: string
}

// 個別のフィールド検証関数
function validateUsername(username: string): Result<string, FormValidationError> {
  if (username.length < 3) {
    return err({
      field: 'username',
      message: 'ユーザー名は3文字以上必要です'
    })
  }
  return ok(username)
}

function validateEmail(email: string): Result<string, FormValidationError> {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!emailRegex.test(email)) {
    return err({
      field: 'email',
      message: '有効なメールアドレスを入力してください'
    })
  }
  return ok(email)
}

function validatePassword(password: string): Result<string, FormValidationError> {
  if (password.length < 8) {
    return err({
      field: 'password',
      message: 'パスワードは8文字以上必要です'
    })
  }
  return ok(password)
}

// フォームの検証
function validateForm(formData: FormData): Result<FormData, FormValidationError> {
  // 各フィールドを検証
  const usernameResult = validateUsername(formData.username)
  const emailResult = validateEmail(formData.email)
  const passwordResult = validatePassword(formData.password)
  
  // 最初に見つかったエラーを返す（早期リターン）
  if (usernameResult.isErr()) return usernameResult.mapErr(e => e)
  if (emailResult.isErr()) return emailResult.mapErr(e => e)
  if (passwordResult.isErr()) return passwordResult.mapErr(e => e)
  
  // すべてのフィールドが有効な場合
  return ok(formData)
}

// 使用例
const formData: FormData = {
  username: 'al',  // 無効
  email: 'alice@example.com',
  password: 'securepassword'
}

const validationResult = validateForm(formData)

validationResult.match(
  validData => {
    console.log("フォームの検証に成功しました:", validData)
    // フォームの送信処理
  },
  error => {
    console.error(`${error.field}が無効です: ${error.message}`)
    // エラーメッセージの表示など
  }
)
```

## combine機能：複数のResultを組み合わせる

複数の`Result`を一度に処理するには、`combine`静的メソッドが便利です：

```typescript
import { ok, err, Result } from 'neverthrow'

// いくつかのResult
const result1 = ok(1)
const result2 = ok(2)
const result3 = err("エラー発生")
const result4 = ok(4)

// 同種のResultを組み合わせる
const results = [result1, result2, result4]
const combined = Result.combine(results)
// combined は ok([1, 2, 4]) となる

// エラーがある場合
const resultsWithError = [result1, result2, result3, result4]
const combinedWithError = Result.combine(resultsWithError)
// combinedWithError は err("エラー発生") となる
// 最初のエラーで短絡評価される

// すべてのエラーを収集したい場合
const allErrors = Result.combineWithAllErrors(resultsWithError)
// allErrors は err(["エラー発生"]) となる
// 複数のエラーがある場合はそれらすべてが配列に含まれる
```

この機能は、例えば複数のフォームフィールドを検証し、すべてのエラーを一度に表示したい場合などに役立ちます。

## テストでのneverthrowの使用

neverthrowはテストを書きやすくします。`Result`インスタンスは比較可能なため、期待値と直接比較できます：

```typescript
import { ok, err } from 'neverthrow'

// テスト用の関数
function divide(a: number, b: number) {
  if (b === 0) {
    return err("ゼロ除算エラー")
  }
  return ok(a / b)
}

// Jestなどのテストフレームワークを使ったテスト
describe('divide', () => {
  it('正常な除算ができること', () => {
    expect(divide(10, 2)).toEqual(ok(5))
  })
  
  it('ゼロ除算でエラーを返すこと', () => {
    expect(divide(10, 0)).toEqual(err("ゼロ除算エラー"))
  })
})
```

また、内部的なテスト用に`_unsafeUnwrap`と`_unsafeUnwrapErr`メソッドも提供されていますが、これらはテスト環境でのみ使用すべきです。

## eslint-plugin-neverthrow

neverthrowを効果的に使うために、eslint-plugin-neverthrowというESLintプラグインが公開されています。このプラグインは、`Result`を適切に処理することを強制し、エラーハンドリングの漏れを防ぎます。

インストール：

```bash
npm install eslint-plugin-neverthrow --save-dev
```

eslint-plugin-neverthrowを使うと、次の3つの方法のいずれかで`Result`を消費することが強制されます：

1. `.match`の呼び出し
2. `.unwrapOr`の呼び出し
3. `._unsafeUnwrap`の呼び出し（テスト用）

これにより、エラーハンドリングを忘れることがなくなります。これはRustの`must-use`属性と同様の機能です。

## Effectとneverthrowの比較

前回の記事で紹介した「Effect」ライブラリとneverthrowの主な違いをおさらいしましょう：

| 特徴 | neverthrow | Effect |
|------|------------|--------|
| 複雑さ | シンプル | 高度 |
| 学習曲線 | 比較的緩やか | 比較的急 |
| 実行モデル | 即時評価 | 遅延評価 |
| 機能範囲 | エラーハンドリングに特化 | 包括的な関数型プログラミング機能セット |
| 依存性注入 | 含まれていない | 組み込みサポートあり |
| 非同期処理 | 基本的なサポート | 高度なサポート |

neverthrowは、特にエラーハンドリングに焦点を当てたシンプルなライブラリであり、導入しやすい点が魅力です。一方、Effectはより豊富な機能を提供しています。

## まとめ

neverthrowは、TypeScriptでの型安全なエラーハンドリングを実現する強力なライブラリです。主なメリットは：

1. **型安全性**: エラーの種類がコンパイル時に明確になります
2. **明示的なエラー処理**: 関数のシグネチャからエラーの可能性が明確になります
3. **合成可能性**: 関数を安全に連鎖させて複雑な操作を構築できます

従来のtry...catchによるエラー処理と比較して、neverthrowを使用することで：

- エラーハンドリングの漏れを減らせる
- コードが読みやすく、メンテナンスしやすくなる
- 型システムの恩恵を最大限に受けられる

特に複数の処理を連鎖させる場合や、明確なエラータイプの区別が必要な場合に、neverthrowは真価を発揮します。個人的にも、neverthrowこそが私の求めていたものだと感じました！

## 参考リンク

- [neverthrow GitHub リポジトリ](https://github.com/supermacro/neverthrow)
- [neverthrow npm パッケージ](https://www.npmjs.com/package/neverthrow)
- [eslint-plugin-neverthrow](https://github.com/mdbetancourt/eslint-plugin-neverthrow)
- [前回の記事: try...catchに頼らない型安全なエラーハンドリング](https://qiita.com/yonaka15/items/276d1ac3e42192037e81)