TypeScriptでRustの`?`やHaskellの`do`っぽくResultを扱う
おことわり
キャッチーなキーワードとしてタイトルに含めてしまいましたが、Haskellについては素人です。
概要
このセクションはタイトルで概要がわかる方はスキップしていただいて大丈夫です。
TypescriptのエラーハンドリングでResultを使うというのはすでに日本語でも記事が何本もありますし、実装もすでにneverthrowのようなパッケージがあります。
ただ、TypescriptにはRustの?のような記法がないので、
import { Result, ok, err } from "neverthrow"
function doAll(): Result<number, string> {
const aResult = doA() // doAはなにかしらのResultを返す関数
if (aResult.isErr()) {
return err("boo")
}
const a = aResult.value // aResult.valueを使い続けるのも可
const bResult = doB(a) // doBはdoAのOkに包まれた型の値を受けてなにかしらのResultを返す関数
if (bResult.isErr()) {
return err("booooo")
}
const b = bResult.value
...
return ok(42)
}
のように手作業でエラーチェックをしてあげるか、またはメソッドチェーンを使って
import { Result, ok, err } from "neverthrow"
function doAll(): Result<number, string> {
return doA()
.mapErr(_ => "boo")
.andThen((a) =>
doB(a)
.map(b => {a, b})
.mapErr(_ => "booooo")
)
.andThen({a, b} => {...})
....
.map({a, b, ...} => {
...
return 42
})
}
のように書く必要があると認識されているのではないかと思います。前者はエラーチェックがボイラープレートですし、後者はmapの中でResultを返す関数を扱う場合や条件分岐が複雑になった場合などにネストがどんどん深くまた複雑になってしまいます。
そこで、Rustの?記法のような書き方をTypescriptで実現した、という記事です。
成果物
以下、Rustの?記法のように、Errの場合はそのまま関数から返し、Okであればその中身として評価して処理を続けることを「?もどき
」と呼びます。
コード例としてneverthrowを使用していますが、適宜修正すればResultの実装にかかわらず適用できるはずです。
説明用のシンプルなバージョン
import { Result, ok, err } from "neverthrow"
// `block`と`returnError`は補助関数
// ?もどきをサポートするブロックを作成する関数
function block<T, E>(
body: () => Generator<Err<never, E>, Result<T, E>>
) {
// bodyで得られるジェネレータに一度だけnext()を呼んでその値を返す。
return body().next().value
}
// `block`の引数`body`の中で
// ```ts
// yield* returnError(result)
// ```
// とするとresultに対して?もどきが行われる
function returnError<T, E>(result: Result<T, E>): Generator<Err<never, E>, T> {
return function*() {
if (result.isOk()) {
return result.value
}
// `Err<T, E>` -> `Err<never, E>`の型変換のためerr(result.error)を呼んでいるが
// 本質はエラーをそのままyieldすることにある。
yield err(result.error)
// このジェネレータはblockで1度だけnextが呼ばれるのでここには到達しない
throw new Error("THIS SHOULD NEVER HAPPEN")
}()
}
// ここから使用例
declare function doA(): Result<string, "bar">
declare function doB(a: string): Result<boolean, "baz">
const x = block<number, "bar" | "baz">(
function*() {
// doAがエラーであれば左辺の代入手前で早期リターンする
// doAがOkだった場合それをunwrapしたものがaに代入される。
// aの型はstring (doAのOkの型)
const a = yield* returnError(doA())
// 以下同様に、ResultがOkの場合のみ処理が続く
const b = yield* returnError(doB(a))
...
return ok(42) // returnは通常通り値を返す
}
)
説明
以下ではyieldやyield*自体については説明しないので必要であればMDN(yield, yield*)等を参照してください。
block
についてはコード内のコメントにもある通り、引数body
から得られるジェネレータを一度だけ消費(next()を呼ぶ)してその値をそのまま返します。
そのため、block
の引数body
では、?もどき
で実際にErrだったもの、もしくはreturnされた値、のうち最初に現れたものを最初にyieldもしくはreturnするジェネレータを返せばいいです。
そこで、returnError(result)
は、引数result
がErrであった場合その値をyieldし、Okであった場合はその中身をreturnするジェネレータを返します。これをbody
内でそのまま yield* すると(ここではyield
ではなくyield*
であることに注意してください)、yield* returnError(result)
式は、result
がErrであった場合はErrをyieldし、Okであった場合は何もyieldせず、Okの中身の値として評価されます。また、Okであった場合は、型も正しく推論されます。よって、yield* returnError(result)
とすることでresult
について?もどき
を行えることになります。
asyncのサポートも含めたバージョン
適当なoverloadを利用して、必要に応じてPromiseの処理を追加したものです。
export const block:
& (<T, E>(
body: () => Generator<Err<never, E>, Result<T, E>>
) => Result<T, E>)
& (<T, E>(
body: () => AsyncGenerator<Err<never, E>, Result<T, E>>
) => Promise<Result<T, E>>)
& {
returnError: <T, E>(result: Result<T, E>) => Generator<Err<never, E>, T>
}
= (() => {
function fBlock<T, E>(
body: () => Generator<Err<never, E>, Result<T, E>>
): Result<T, E>
function fBlock<T, E>(
body: () => AsyncGenerator<Err<never, E>, Result<T, E>>
): Promise<Result<T, E>>
function fBlock<T, E>(
body:
| (() => Generator<Err<never, E>, Result<T, E>>)
| (() => AsyncGenerator<Err<never, E>, Result<T, E>>)
) {
const n = body().next()
if (n instanceof Promise) {
return n.then(r => r.value)
}
return n.value
}
return Object.assign(
fBlock,
{
returnError: <T, E>(result: Result<T, E>): Generator<Err<never, E>, T> => {
return function*() {
if (result.isOk()) {
return result.value
}
yield err(result.error)
throw new Error("THIS SHOULD NEVER HAPPEN")
}()
}
},
)
})()
ちなみに
この記事は、発見してうれしくなってIssue立ててしまったその勢いで書いています。
追記:マージ&リリースされました
Discussion