🥏

neverthrow の全機能リファレンス

2024/04/09に公開

JavaScript/TypeScript で try/catch を使わないエラーハンドリングに利用できるライブラリとしてはそこそこ有名だと思う neverthrow ですがあまり解説された記事が少なく、関数型と手続き型の書き方をいい感じにミックスできるいいライブラリで情報の少なさから選択されないのも勿体なく感じました。ちょうど GitHub のドキュメントを読みながら意訳してまとめた手元の技術メモがありますのでその一助になればと公開します。

neverthrow とは

supermacro/neverthrow: Type-Safe Errors for JS & TypeScript
プログラムのエラーハンドリングを try - catch ではなく関数型プログラミング由来の Result 型や Either 型と呼ばれる方法で実現する機能を提供するライブラリです。具体的には Result 型を提供してくれ Option 型が値の有無を表すように処理の成功/失敗を表してくれます。

例えばバックエンドのリクエストを受けてレスポンスを返すまでの処理フローは、neverthrow を使うと以下のように記述することが出来ます。途中の処理でなにかエラーが置きた場合、最後のレスポンスを返す処理まで残りの処理はスキップされます。

ok(request)
  .andThen((request) => validate(reqest))
  .andThen((validatedRequest) => createUser(validatedRequest))
  .andThen((record) => toResponse(record))
  .match(
    (response) => res.status(200).json(response),
    (error) => res.status(error.statusCode).json(error.data),
  )

突き詰めれば関数型プログラミング的に全ての関数をこのように書くことも出来るのですが、neverthrow は手続き的に書いたものを Result に変換するサポートも行ってくれるので、要所に応じて使い分ける事ができます。

似たような事ができるライブラリとしては fp-ts があり、こちらは Either 型だけでなく様々な関数型プログラミングを支援する機能があるライブラリです。本格的に関数型プログラミングに取り組みたい時はこちらの方がおすすめですが、エラーハンドリング周りだけ関数型プログラミングの要素を取り入れたい時には neverthrow はおすすめです。

インストール

npm install neverthrow
npm install eslint-plugin-neverthrow

mdbetancourt/eslint-plugin-neverthrow
必須ではないけどエラー処理がされていない Result に対して警告を出してくれます。

API

neverthrow から export されている関数や型です。

  • Result, ResultAsync
    • 成功/失敗を表す型
    • 定義は Result<T, E> = Ok<T, E> | Err<T, E> ( T : 成功値, E : 失敗値 ) となります
    • ResultAsync は非同期版で Promise<Result> を Result と同じように扱えます
  • Ok
    • 成功を表す型で value プロパティに成功値を持つ
  • Err
    • 失敗を表す型で error プロパティにエラー値を持つ
  • ok, okAsync
    • 引数を Ok 型にして返す関数
  • err, errAsync
    • 引数を Err 型にして返す関数
  • fromThrowable
    • 例外を投げる関数を Result を返す関数にして返す関数
  • fromPromise
    • 例外を投げる可能性がある PromiseResultAsync に変換する関数
  • fromSafePromise
    • 例外を投げない事がわかっている Promise 向けの fromPromise
  • safeTry
    • ボイラープレート的なエラーハンドリングを簡潔な記述にできるユーティリティ関数

fromThrowable, fromPromise, fromSafePromise は Result/ResultAsync の static method に同様のものがあり、使い方は同じなので説明はそちらを参照下さい。

safeTry は Result/ResultAsync の safeUnwrap メソッドと一緒に使う関数で、こちらの使い方も後述しています。

Result API

  • Result.isOk
    • Ok 値であれば true を返す
  • Result.isErr
    • Err 値であれば true を返す

後述の unwrapOrmatch 以外でフローの締め処理を行いたい時などに利用できます。

const result = someFunction()
if (result.isOk()) {
  // Ok 型が確定する
  console.log(result.value)
}
if (result.isErr()) {
  // Err 型が確定する
  console.log(result.error)
}

Result.map

Result<T, E>Result<U, E> と成功時の値を変換する関数で、Ok 値である場合のみに実行されます。引数にわたす関数の型は (value: T) => U です。

// map<U>(callback: (value: T) => U): Result<U, E> { ... }

ok("1").map(v => Number(v))

Result.asyncMap

Result.map の非同期版で Promise を返す関数を引数に取り、戻り値として ResultAsync を返します。

Result.mapErr

Result<T, E>Result<T, F> と失敗時の値を変換する関数で、Err 値である場合のみに実行されます。引数にわたす関数の型は (value: E) => F です。

// mapErr<F>(callback: (error: E) => F): Result<T, F> { ... }

err("error").mapErr(error => log.Fatal(error))

Result.andThen

基本的な考えは Result.map と同じで Ok 値の場合のみ実行される関数です。map とは違って引数に渡す関数が値ではなく Result を返す必要があります。引数の関数を型にすると (value: T) => Result<U, F> です。

// andThen<U, F>(callback: (value: T) => Result<U, F>): Result<U, E | F> { ... }

const double = v => ok(v + v)

ok(2).andThen(double).andThen(double) // Ok(8)
err(2).andThen(err).andThen(double) // Err(2)

Result.asyncAndThen

Result.andThen と同様の機能の非同期版で ResultAsync を返す関数を引数に取ります。処理途中から非同期処理を行いたい場合に利用できます。

Result.orElse

Result.andThen のエラー処理版のような関数で Err 値の場合にのみ実行される関数です。新しい Result を返す事ができるので、特定のエラーの場合にはリカバリー処理を行って処理を続行させる事もできます。

// orElse<A>(callback: (error: E) => Result<T, A>): Result<T, A> { ... }

queryError.orElse(error => {
  error === QueryError.NotFound
    ? ok('user not found') // 処理は成功したが見つからなかったとして処理する
    : err(500)             // internal server error として処理する
})

Result.unwrapOr

Ok 値であればその値を、Err 値であれば引数に渡したデフォルト値を返す関数です。

// unwrapOr<T>(value: T): T { ... }

ok(2).unwrapOr(10) // -> 2
err("error").unwrapOr(10) // -> 10

Result.match

Ok 値に対する関数と Err 値に対する関数の 2 つの引数をとり、実際の値が一致する方の関数が実行されます。それぞれの関数で結果を返すこともできますが、この時の戻り値の型は共通のものにする必要があります。また、match の戻り値は Result 型ではなく返した値の型です。

class Result<T, E> {
  match<A>(
    okCallback: (value: T) => A,
    errorCallback: (error: E) => A,
  ): A => { ... }
}

Result.fromThrowable (static method)

例外を投げる可能性がある関数と投げられた例外を変換する関数の 2 つを引数に取り、結果を Result で返す関数に変換してくれる関数です。例外を投げる外部のライブラリを neverthrow を使った処理フローが行えるようにしたい場合に利用します。

const safeJsonParse = Result.fromThrowable(JSON.parse, toParseError)

// 元の関数と同じように使えるが結果は Result として返る
const result = safeJsonParse(jsonString)

同様の関数が通常の関数としても定義されている。

import { fromThrowable } from 'neverthrow'

Result.combine (static method)

Promise.all のように Result のリストを 1 つの Result にまとめる関数です。成功や失敗の値の型が異なっていてもまとめる事はできるが、Result と ResultAsync を混ぜる事はできません。また、渡すリストは配列だけでなくタプルも渡せます。
combine は引数に渡したリストの全ての Result が Ok 値の場合はそれらの値のリストを持った Ok を返し、Err 値が含まれている場合は最初に見つかった Err を結果として返します。

結果の型が異なる配列の場合はユニオン型の配列が結果の型となります。

const arr = [ok(1), ok('ok')] // (Result<number, never> | Result<string, never>)[]
const combinedArr = Result.combine(arr) // Result<(string | number)[], never>

combine に渡す際に配列化して渡そうとするとタプルとして扱われます。変数として渡したい場合は変数の型を明示的に書く方法もありますが、タプルを返すユーティリティ関数を用意すると型推論により型の変更に対しても柔軟に対応できます。

Result.combine([ok(1), ok('ok')]) // Result<[number, string], never>

const tuple = <T extends any[]>(...args: T): T => args
const resultTuple = tuple(ok(1), ok('ok'))
Result.combine(resultTuple) // 上記と同じ結果の型となる

Result.combineWithAllErrors (static method)

Result.combine と同じく結果をまとめる関数ですが、リストに Err 値が複数含まれていた場合に全ての Err をリストとして返します。

Result.combineWithAllErrors([ok(1), err(2), err(3)]) // Err([2, 3])

ResultAsync API

基本的に Result API と同様のものが使えるので特有のものだけ紹介します。

ResultAsync.fromPromise (static method)

例外を投げる可能性がある PromiseResultAsync に変換する関数です。具体的には内部で Promisethen 及び catch が処理され結果を ResultAsync にして返します。
第 2 引数には投げられた例外を変換する関数を渡す必要があります。後続の処理で扱いやすいようにオブジェクトに変換することが推奨されています。例外を投げないことがわかっている場合は冗長になるので、後述する fromSafePromise を使えば第 2 引数を省略できます。

ResultAsync.fromPromise<T, E>(
  promise: PromiseLike<T>,
  errorHandler: (unknownError: unknow) => E)
): ResultAsync<T, E> { ... }

ResultAsync.fromPromise(insertUser(request), () => new Error('database error'))

ResultAsync.fromSafePromise (static method)

ResultAsync.fromPromise と同様に PromiseResultAsync に変換する関数です。fromPromise とは違い Promise が例外を投げないことがわかっている時に利用します。この関数内では例外が投げられても Promisecatch が処理されないので注意が必要です。

safeTry

Result を返す関数の中で複数の Result を返す関数を呼び出し、それらの結果の値を取り出して処理する必要がある場合、通常は以下のようなコードになります。

const example = (): Result<number, string> => {
  const result1 = foo()
  if (result1.isErr()) {
    return err(`aborted: ${result1.error}`)
  }

  const result2 = bar()
  if (result2.isErr()) {
    return err(`aborted: ${result2.error}`)
  }

  return ok(result1.value + result2.value)
}

このボイラープレート的な手続きを safeTryResult/ResultAsyncsafeUnwrap を利用する事で以下のように書くことができます。(ResultAsync の場合は yield* の後に await が必要になります)

const example = (): Result<number, string> => {
  return safeTry<number, string>(function* () {
    const result1 = yield* foo().mapErr(e => `aborted: $(e)`).safeUnwrap()
    const result2 = yield* bar().mapErr(e => `aborted: $(e)`).safeUnwrap()
    return ok(result1 + result2)
  })
}

ジェネレーターと safeUnwrap の工夫により、処理の途中で Err を返した関数があればその時点で中断し、 safeTry の結果は上記の例だと mapErr の値となり、全ての関数が Ok を返した場合は最後まで処理された値が返ります。

公式のサンプルだと関数型言語に近い書き方に一気に修正されているので、人によっては可読性の悪いコードに見えたので抵抗があったのですが、上記のように if による分岐とは異なるエラーハンドリングの形式が提供されていると理解できれば良い機能だと感じました。

Testing API

Result のインスタンスは比較可能なので通常通りテストすることが出来ます。

expect(result).toEqual(ok(value))

テスト用に以下のような関数も用意されています。

  • Result._unsafeUnwrap
    • Ok 値なら結果を返し、Err 値ならカスタムオブジェクトを返す
  • Result._unsafeUnwrapErr
    • Err 値ならエラーを返し、Ok 値ならカスタムオブジェクトを返す

また、これらは通常はスタックトレースを含みませんがオプションを渡すことによりスタックトレースを生成する事も出来ます。

_unsafeUnwrap({ withStackTrace: true })

参考

Discussion