TSのResult型について知りたい
この記事がきっかけで、そもそものResult型のMotivationから整理するのをやりたくなった。自分も各地でおれおれResultを見てきた経験があって...
いま中途半端に色々読んじゃってて理解の途中からスクラップを開いた形になる。スクラップ自体は途中からだけど、スクラップ開ける前に読んだ記事もあとで貼る。
fp-tsとneverthrowとoption-tがどうやら代表的なライブラリらしいというのがなんとなくわかっていて、Ruselt型だけ導入したいときは後ろ2つのどちらかを選ぶことが多くて、その2つの比較をoption-tの作者が書いてくれているらしい?
neverthrowと比較した場合の設計意図の違いについて
ざっと見た場合、以下が設計方針として違うかなと思う。
- documentation(上で述べた通り)
- メソッドチェーンの有無
- classベースの実装か否か
- tree shakingのサポート
- Result 型以外の実装も提供している
result-type-tsではTree shakingを取らなかったらしいが
こういう設計にするとTree shakingが効かなくなってしまうのですが、Result型だけの小さなライブラリなので普通のプロジェクトではバンドルサイズはほとんど誤差になってメリットの方が上回るのではないかと考えました。
option-tではバンドルサイズを重視してこの選択
一般的なアプリケーションから、配布サイズにセンシティブなSDKや配信ウィジェットでの利用まで含めて実用的にしようと思うと、この選択となった。
Result 型に対するオペレータ関数は実質無限に近しいパターンを実装可能であり、頻出パターンはライブラリとして提供するのが望ましい一方、それら全てが常に使われるわけでは無いという問題と隣り合わせである。
どっちもわかる、実際どのくらいの差が出るんだろう。
メソッドチェーンあるとandThenとかは強いよね。その場で中身の加工をちゃちゃっとしたいときに書きやすい。
option-tのmotivation
And Rust's std::option and std::result are suggestive to achieve these conventions in practice. Thus this package is inspired by their design.
そう、なんか局所的にRustのドキュメント読めばわかるよっていうのを何回か見かけた。
Uniform the expression of "none" value.
「undefined / null / -1 みたいに色々あるけど統一したい」
Uniform the way to carry error information instead of throwing an error.
Thus this library provides a way to express recoverable error and also recommends to use throwing an error only if you intend to throw an unrecoverable error. This categorization introduces a convenient convention for you:
- If the code uses throw, you should be careful about unrecoverable error.
- If the code returns Result<T, E> provided this library, then you should handle it correctly.
Rustの「回復可能かどうか」というエラー分類にinspireされ、「回復可能ならResult型を返し、そうじゃないならthrow Errorする」という考え方をしているらしい
neverthrowとの違いはREADMEにも詳しく書いてあった。内容はさっきのブログと大体同じ。
バンドルサイズを比較しようとしたが、option-tが出てこなかった。
neverthrow
result-type-ts
あ、完全にtree shakingできるからってことか?
には
なので既に何人もの開発者がResult型のnpmパッケージを公開しているのですが、自分好みのものが見当たらなかったので自作しました。
とあるが、どこらへんが「自分好み」(=他との差別化)なんだろう
そのおかげでResultだけをimportすれば済みますし、関数名などを覚えていなくてもエディターの候補表示から全てのユーティリティを辿れるようになっています。
これかな?
あーいや、interfaceだいぶ違うな?
neverthrowは.unwrapOrで値を取り出すけど、こっちは.valueで取り出す?
え、neverthrowに.value相当のものないのか?
いや、ありそう。READMEのexampleより・
import { findUsersIn } from 'imaginary-database'
// ^ assume findUsersIn has the following signature:
// findUsersIn(country: string): ResultAsync<Array<User>, Error>
// Let's say we need to low-level errors from findUsersIn to be more readable
const usersInCanada = findUsersIn("Canada").mapErr((error: Error) => {
// The only error we want to pass to the user is "Unknown country"
if(error.message === "Unknown country"){
return error.message
}
// All other errors will be labelled as a system error
return "System error, please contact an administrator."
})
// usersInCanada is of type ResultAsync<Array<User>, string>
usersInCanada.then((usersResult: Result<Array<User>, string>) => {
if(usersResult.isErr()){
res.status(400).json({
error: usersResult.error
})
}
else{
res.status(200).json({
users: usersResult.value
})
}
})
option-tに関してもresult.valで取れるのかな?
あ、これはok.valなだけか。そりゃそうだ。
どこらへんが「自分好み」(=他との差別化)なんだろう
まだResult型をちゃんと使ったことがないのでよくわからなかった。
今のところの印象ではメソッドチェーンを書きたいのでoption-tよりはneverthrowのが好きそう。ただ、richすぎるのはそうだなって思う。全部のメソッドを使いこなす気がしないのでtree shakingしてくれはそうかも。
neverthrow使ってみて「べつにメソッドチェーンそんな使わんわ」ってなったらoption-tすればいいのか
逆かな?option-tから始めて、あまりに書きにくかったらneverthrowで改善できるかどうかを考えればいいのか
個別の書き味とかに深入りする前にもうちょっと概観したくて
いろんなところで言及されてるのがこの記事
Result型の主なモチベーションは理解した
しかし f1 が例外を投げるかどうかは実装を読まないとわからないので、try catch を使うのを忘れることがあるかもしれない。特にライブラリを使っていると、そのライブラリの挙動を完全に知っていないといつ例外が投げられるかという恐怖がつきまとい、難しい問題だ。
(中略)
として val を使うためには必ず f1Result の ok, error 検証が必要となる。 つまり失敗するかもしれないという文脈を型検査で確かめることが強制されるのである。
この記事でoption-tが推されてて、それを読んだ人がneverthrowが好きって言ってて、それを見かけたoption-tの作者が比較記事を書いてくれたのだった
あ、Rustのドキュメント読めばわかるよってここに書いてあったんだ。
このライブラリのいいところは Rust の標準ライブラリに影響を受けているので、Rust 標準ライブラリの combinator が備わっていたり、使い方のドキュメントは Rust のドキュメントを読めばいいところにある。Rust は難しい印象もあるがドキュメントの生成機能がすごいこともあって Example の充実がすごく、Rust を読めなくても Result のリファレンス・教科書としてまで使えるクオリティなのでチームに導入する時も使いやすい。
というかRustを勉強しろ()
fetchの例がわかりやすい。fetchて大変だな...
async getUserById(id: number): Promise<Result<Object, RepositoryError>> {
let res;
try {
res = await fetch(`${URL}/users/${id}`);
} catch (e) {
const error = new FetchMethodError(
JSON.stringify({
reason: "fail to fetch",
url: URL,
payload: { id },
}),
{ cause: e }
);
loggingException(error);
return createErr(error);
}
if (!res.ok) {
switch (res.status) {
case 401: {
const error = new AuthorizationError(
JSON.stringify({
reason: "fail to fetch by miisng auth",
url: URL,
payload: { id },
res,
})
);
loggingException(error);
return createErr(error);
}
default: {
const error = new InternalError(
JSON.stringify({
reason: "internal server error",
url: URL,
payload: { id },
res,
})
);
loggingException(error);
return createErr(error);
}
}
}
let data;
try {
data = await res.json();
} catch (e) {
const error = new ResponseParseError(
JSON.stringify({
reason: "fail to parse",
url: URL,
payload: { id },
}),
{ cause: e }
);
loggingException(error);
return createErr(error);
}
if (validate(data)) {
return createOk(data);
} else {
const error = new ValidationError(
JSON.stringify({
reason: "fail to validate",
url: URL,
response: { data },
})
);
loggingException(error);
return createErr(error);
}
}
Typeboxをつかってる手元のプロジェクトでは、こういうlib書いておけばいいのでは、となった
export const callApi = async <T extends TSchema>(
path: string,
validator: TypeCheck<T>,
init?: RequestInit
): Promise<Result<Static<T>, RepositoryError>> => {
let res
try {
res = await fetcher(path, init)
} catch (e) {
const error = new FetchMethodError(
JSON.stringify({
reason: 'failed to fetch',
path,
}),
{ cause: e }
)
return fail(error)
}
if (!res.ok) {
switch (res.status) {
case 401: {
const error = new AuthorizationError(
JSON.stringify({
reason: 'failed to fetch by miisng auth',
path,
res,
})
)
return fail(error)
}
default: {
const error = new InternalError(
JSON.stringify({
reason: 'internal server error',
path,
res,
})
)
return fail(error)
}
}
}
let data
try {
data = await res.json()
} catch (e) {
const error = new ResponseParseError(
JSON.stringify({
reason: 'failed to parse',
path,
}),
{ cause: e }
)
return fail(error)
}
if (!validator.Check(data)) {
const cause = Array.from(getJobsResponseValidator.Errors(data))
const error = new ValidationError(
JSON.stringify({
reason: 'failed to validate',
path,
response: { data },
}),
{ cause }
)
return fail(error)
}
return succeed(data)
}
この記事で、↑の記事と別角度の実践テクが紹介されてた
全体設計からみたときの話と、「Result使ってても返り値に着目しないときに処理忘れがちよね?」みたいな話が目新しかったかな?
const handleDelete = async (id: string) => {
await deleteUser(id)
}
option-tは結果が T | null | undefined
になるのいやかなって思ったんだけど、Tに絞らせる術はあるだろうか
ふつうに早期リターンで絞れた
一旦、これでResult型入門はできてそうなので今回はよしとする
もうちょい研究進んだらなんか書くかも