昨日の記事「【TypeScript】try...catchに頼らない型安全なエラーハンドリング」では、TypeScriptでのエラー処理における課題と、その解決策としてeffect-ts
とts-result
による「Result型パターン」について紹介しました。
今回は、その続編として、Result型パターンを実装したライブラリの一つである「neverthrow」について解説します。
前回紹介した課題をおさらいしておくと:
-
型の安全性がない: try...catchでは、エラーの型が
any
またはunknown
になり、どのような種類のエラーが発生するかコンパイル時に把握できない - 暗黙的な制御フロー: 関数のシグネチャからは、その関数が例外をスローする可能性があるかどうかわからない
- 捕捉すべき場所が不明確: どこでエラーをキャッチすべきか、コードから読み取りにくい
neverthrowは、これらの問題を解決するためのTypeScriptライブラリで、RustのResult
型に着想を得た型安全なエラーハンドリングを提供します。今回はneverthrowの基本的な使い方から実践的なケースまで解説していきます。
neverthrowとは
neverthrowは、エラーを値として扱い、成功(Ok
)または失敗(Err
)のいずれかを表現するResult
型を中心としたライブラリです。関数が例外をスローする代わりにResult
を返すことで、型安全性を保ちながらエラーハンドリングを行うことができます。
同期処理にはResult
型、非同期処理にはResultAsync
型を使用します。ResultAsync
はPromise<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による変換
値を変換するにはmap
とmapErr
メソッドを使います:
// 成功値の変換
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の基本
ResultAsync
はPromise<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
を消費することが強制されます:
-
.match
の呼び出し -
.unwrapOr
の呼び出し -
._unsafeUnwrap
の呼び出し(テスト用)
これにより、エラーハンドリングを忘れることがなくなります。これはRustのmust-use
属性と同様の機能です。
Effectとneverthrowの比較
前回の記事で紹介した「Effect」ライブラリとneverthrowの主な違いをおさらいしましょう:
特徴 | neverthrow | Effect |
---|---|---|
複雑さ | シンプル | 高度 |
学習曲線 | 比較的緩やか | 比較的急 |
実行モデル | 即時評価 | 遅延評価 |
機能範囲 | エラーハンドリングに特化 | 包括的な関数型プログラミング機能セット |
依存性注入 | 含まれていない | 組み込みサポートあり |
非同期処理 | 基本的なサポート | 高度なサポート |
neverthrowは、特にエラーハンドリングに焦点を当てたシンプルなライブラリであり、導入しやすい点が魅力です。一方、Effectはより豊富な機能を提供しています。
まとめ
neverthrowは、TypeScriptでの型安全なエラーハンドリングを実現する強力なライブラリです。主なメリットは:
- 型安全性: エラーの種類がコンパイル時に明確になります
- 明示的なエラー処理: 関数のシグネチャからエラーの可能性が明確になります
- 合成可能性: 関数を安全に連鎖させて複雑な操作を構築できます
従来のtry...catchによるエラー処理と比較して、neverthrowを使用することで:
- エラーハンドリングの漏れを減らせる
- コードが読みやすく、メンテナンスしやすくなる
- 型システムの恩恵を最大限に受けられる
特に複数の処理を連鎖させる場合や、明確なエラータイプの区別が必要な場合に、neverthrowは真価を発揮します。個人的にも、neverthrowこそが私の求めていたものだと感じました!