0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

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

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

neverthrowとは

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

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

インストール方法

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

npm install neverthrow
# または
yarn add neverthrow

基本的な使い方

1. Result型の作成

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

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()メソッドを使います:

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

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

3. map、mapErrによる変換

値を変換するにはmapmapErrメソッドを使います:

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

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

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

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

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は、前の操作が成功した場合にのみ次の操作を実行します。これにより、複数の操作を連鎖させる際に、エラーハンドリングを簡潔に書けます:

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の基本

ResultAsyncPromise<Result<T, E>>のラッパーですが、Resultと同様のAPIを提供します:

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メソッドを使います:

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で連鎖させることができます:

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パターンに変換するためのヘルパー関数:

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. カスタムエラー型の使用

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

// エラー型の定義
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が特に有用なユースケースです:

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静的メソッドが便利です:

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インスタンスは比較可能なため、期待値と直接比較できます:

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を適切に処理することを強制し、エラーハンドリングの漏れを防ぎます。

インストール:

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こそが私の求めていたものだと感じました!

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?