現在このリポジトリはアーカイブされています。
こちらのリポジトリで引き続き改善を続けています。
https://github.com/totto2727-org/js-package/tree/main/package/result
このパッケージはTypeScript環境にResult
型および関連するユーティリティを追加します。
Result(Either)型はKotlinやRust、Haskellに標準で導入されている型であり、処理が失敗する可能性を示す型です。
これにより、JavaScript標準のtry catch
の問題点である、「どのような例外が発生するのか型から判別することができない」という問題を解決できます。
- クラスではなく、オブジェクトと関数で実装をしています
- 最適化によりコードが数kBに縮小されます
- Next.js(App Router)やRemix、Qwik Cityといったクライアントサイドとサーバサイドを統合するフレームワークでも、スムーズにResult型の恩恵を受けることができます。
- 全てイミュータブルな実装がされています
- 遅延評価と正確評価の両方で実装されています
- 関数合成(remedaの
pipe
関数やflow
関数など)が容易な実装です
tsconfig.json
は以下の設定のいずれかである必要があります。
"moduleResolution": "node16",
"moduleResolution": "nodenext",
"moduleResolution": "bundler",
npm add @totto2727/result
yarn add @totto2727/result
pnpm add @totto2727/result
bun add @totto2727/result
import * as r from "@totto2727/result"
import * as rEager from "@totto2727/result/eager"
import * as rLazy from "@totto2727/result/lazy"
import * as r from "https://esm.sh/@totto2727/result"
import * as rEager from "https://esm.sh/@totto2727/result/eager"
import * as rLazy from "https://esm.sh/@totto2727/result/lazy"
以下のサンプルは全てDenoおよびブラウザ環境を想定しています。
// NANを返すことは避けたい
function divide(dividend: number, divisor: number): number {
if (y === 0) {
throw new Error("0除算は禁止されています");
}
return dividend / divisor;
}
// 例外発生!
const quotient = devide(1, 0);
// 実行前にアプリケーションが異常終了
console.log(quotient);
上記のサンプルでは、devide関数が例外を発生させる可能性を型から読み取ることはできません。 コメントやJS Docを記述することで対策は可能ですが、tscコマンドがコンパイルエラーを発生させることはありません。 誰かかがうっかり0を渡すと、アプリケーションが予期せぬ例外により終了する可能性があります。
import * as r from "https://esm.sh/@totto2727/result"
// NANを返すことは避けたい
function divide(dividend: number, divisor: number): r.Result<number, string> {
if (y === 0) {
return r.fail("0除算は禁止されています");
}
return r.succeed(dividend / divisor);
}
const quotient = devide(1, 0);
// 失敗したか判別するユーザ定義型ガード
if (r.isFailure(quotient)) {
// 以下quotientはFailure型として処理される
consolo.log(quotient.cause)
// output: 0除算は禁止されています
}
// 以下quotientはSuccess型として処理される
console.log(quotient.value)
// output: ${計算結果}
これであればどうなるでしょうか?
divide
関数はnumber
型ではなくResult<number, string>
型を返すため、計算結果を利用するために必ず成否の確認が必要となります。
もし、成否の確認を忘れてもtsc
がコンパイルエラーを出力して確認忘れを教えてくれることでしょう。
これで予期せぬ例外によるアプリケーションの終了を避けることができました!
また、失敗の型を詳細に記述することで失敗の原因を型から読み取ったり、原因の絞り込みが容易になります。
逆にエラーの詳細を他の処理に渡す必要がなければ、AnyhowResult
型で型の記述量を減らすこともできます。
下記では基本的な型と関数に加え、型の複雑性を解消するユーティリティ型および関数の解説を行います。
import * as r from "https://esm.sh/@totto2727/result"
type OriginalResult = r.Result<"成功", "失敗">
Result
型を利用する際は、succeed
関数やfail
関数を利用するば簡単に値を生成できます。
import * as r from "https://esm.sh/@totto2727/result"
function returnResult(): r.Result<string, string> {
// なんらかの処理
if (error) return r.fail("任意のエラーに関する値");
return r.succeed("処理に成功しました!");
}
Note
fail
関数でもResult
型の利用は可能ですが、より詳細な例外の管理のためにfailTyped
関数の利用を推奨します。 詳細は下記の応用を参照してください。
isSuccess
関数とisFailure
関数を利用すると、成功した場合、失敗した場合の処理を効率よく型安全に記述することができます。
import * as r from "https://esm.sh/@totto2727/result"
const result = returnResult()
if (r.isSuccess(result)) {
// 処理に成功した時の処理
// result.valueがResult<T, U>のTに推論されます
const success = result.value;
// ...
}
if (r.isFailure(result)) {
// 処理に失敗した時の処理
// result.causeがResult<T, U>のUに推論されます
const failure = result.cause;
// ...
}
Result型を通常の値に変換する関数です。 フロントエンドの状態管理など、Result型のままでは扱いにくい場面で使用することで、素の値となり以降のコードをシンプルに記述する事ができます。
Warn
原則、trycatch文で覆われていることが確実な状況で使用してください。 パニックを起こす可能性や、なんらかの根拠から確実に成功することがわかっている場合は、コードの前後に必ずコメントを記述してください。 これにより、実装者以外がコードの文脈を汲み取りやすくなります。
import * as r from "https://esm.sh/@totto2727/result"
const result = returnResultF();
// Success型であればResult型を外した値が返ってくる
// Failure型の場合、causeがthrowされる
const value = r.unwrap(result);
どのような原因で失敗するかは不明なResult
型の拡張です。
この型でも失敗の原因を絞り込み、cause
プロパティを利用することも可能ですが、unknown
型になるため扱い辛くなります。
そのため、失敗の詳細を知る必要がない場合や、段階的にResult
型を導入する過程で失敗に関する型定義が不十分である場合のみ利用してください。
厳密に失敗周りの型定義が可能な場合は、基本的にTypedResult
型の利用してください。
import * as r from "https://esm.sh/@totto2727/result"
// 実装
// type AnyhowFailure = Failure<unknown>;
function returnAnyhowResult(): r.AnyhowResult<string> {
// なんらかの処理
// fail関数はどのような値でも取れる
if (error1) return r.fail("任意のエラーに関する値");
else if (error2) return r.fail(1);
return r.succeed("処理に成功しました!");
}
失敗の原因が厳密に定義されているResult
型の拡張です。
TypedCause
型とfailTyped
関数(下記参照)のラッパーを定義することで、絞り込みが容易なResult
型を定義することができます。
失敗のキーと詳細はそれぞれ別のプロパティとしています。
そのため、DB由来する失敗は全て同じキーとしつつ、ユーザに提供するメッセージを個別に提供するといったことが可能です。
import * as r from "https://esm.sh/@totto2727/result"
// 第1引数が型絞り込み用の文字列リテラル
// 第2引数が失敗に関する任意の型
type InternalCause = r.TypedCause<"InternalError", string>;
// 特定の失敗を返すためのラッパー関数
function failInternal(message: string) {
return r.failTyped("InternalError", message);
}
function returnTypedResult(): r.TypedResult<string, InternalCause> {
// なんらかの処理
if (error1) return failInternal(JSON.stringify(error1))
else if (error2) return failInternal(JSON.stringify(error2))
return r.succeed("処理に成功しました!");
}
第1引数に失敗の原因を示すキー、第2引数に失敗の詳細を示す値を渡すことができます。
上記のTypedResult
型との併用を想定しています。
Result型からSuccess型の型変数を抽出する型関数です。
import * as r from "https://esm.sh/@totto2727/result"
type OriginalResult = r.Result<string, number>
type OriginalSuccess = r.ExtractSuccess<OriginalResult> // string
Result型からFailure型の型変数を抽出する型関数です。
import * as r from "https://esm.sh/@totto2727/result"
type OriginalResult = r.Result<string, number>
type OriginalSuccess = r.ExtractFailure<OriginalResult> // number
以下の関数には全て正格評価バージョン(@totto2727/result/eager
)と遅延評価バージョン@totto2727/result/lazy
が存在します。
手続的な処理は正格評価バージョン、remeda
のpipe
関数のような関数合成では遅延評価バージョンを利用することで効率的なプログラミングが可能です。
Note
関数言語圏における
Monad
の仕組み把握しておくと、以下の関数の仕様を把握しやすくなります。 fp-tsを用いたMonadの解説
Result
型の値に関数を適用する関数です。
Success
型であれば関数を適用し、Failure
型であればそのままバイパスします。
この関数を用いることで、型ガード関数による検証なしでResult
型の操作が可能になります。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: number) => `${x}`;
const fn2 = (x: number) => x + 1;
const returnResult: () => r.AnyhowResult<number> = () => r.succeed(1);
const result = returnResult(); // Success 1
const eagerMapped = rEager.map(result, fn); // Success "1"
const mappedFn: (x: r.AnyhowResult<number>) => r.AnyhowResult<string> = rLazy.map(fn);
const lazyMapped = mappedFn(result); // Success "1"
const piped = remeda.pipe(result, rLazy.map(fn2), rLazy.map(fn)) // Success "2"
Result
型の失敗の原因に関数を適用する関数です。
Failure
型であれば関数を適用し、Success
型であればそのままバイパスします。
この関数を用いることで、型ガード関数による検証なしでResult
型の操作が可能になります。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: string) => x.length;
const fn2 = (x: string) => `${x}${x}`;
const returnResult: () => r.Result<unknown, string> = () => r.fail("error");
const result = returnResult(); // Failure "error"
const eagerMapped = rEager.mapError(result, fn); // Failure 5
const mappedFn: (x: r.Result<unknown, string>) => r.Result<unknown, number> = rLazy.mapError(fn);
const lazyMapped = mappedFn(result); // Failure 5
const piped = remeda.pipe(result, rLazy.mapError(fn2), rLazy.mapError(fn)) // Failure 10
Result
型の値にResult
型を返す関数を適用する関数です。
Success
型であれば関数を適用し、Failure
形であればそのままバイパスします。
この関数を用いることで、型ガード関数なしかつ、Result
型をネストさせることなく、処理をチェーンさせることができます。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: number): r.AnyhowResult<string> => r.succeed(`${x}`);
const fn2 = (x: number): r.AnyhowResult<number> => r.succeed(x + 1);
const returnResult: () => r.AnyhowResult<number> = () => r.succeed(1);
const result = returnResult(); // Success 1
const eagerMapped = rEager.flatMap(result, fn); // Success "1"
const mappedFn: (x: r.AnyhowResult<number>) => r.AnyhowResult<string> = rLazy.flatMap(fn);
const lazyMapped = mappedFn(result); // Success "1"
const piped = remeda.pipe(result, rLazy.flatMap(fn2), rLazy.flatMap(fn)) // Success "2"
Result
型の失敗の原因にResult
型を返す関数を適用する関数です。
Failure
型であれば関数を適用し、Success
型であればそのままバイパスします。
この関数を用いることで、型ガード関数なしかつ、Result
型をネストさせることなく、処理をチェーンさせることができます。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: string) => r.fail(x.length);
const fn2 = (x: string) => r.fail(`${x}${x}`);
const returnResult: () => r.Result<unknown, string> = () => r.fail("error");
const result = returnResult(); // Failure "error"
const eagerMapped = rEager.flatMapError(result, fn); // Failure 5
const mappedFn: (x: r.Result<unknown, string>) => r.Result<unknown, number> = rLazy.flatMapError(fn);
const lazyMapped = mappedFn(result); // Failure 5
const piped = remeda.pipe(result, rLazy.flatMapError(fn2), rLazy.flatMapError(fn)) // Success 10
例外を投げる可能性がある関数をResult
型もしくはResult
型を返す関数に変換する関数です。
正格評価の場合、第一引数は引数を持たない関数となります。
遅延評価の場合、第一引数は任意の引数を持つ関数となります。
どちらの場合でも、例外発生時に実行される関数の引数の型は実装者が責任を持って設定する必要があります。
どのような例外が発生するか不明な場合は、引数の型をunknown
型に設定し、任意のTypedCause
型を返却することで例外を扱いやすくなります。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: number) => `${x}`;
const fn2 = (x: number) => x + 1;
const throwableFn = (x: number) => {
if (x % 2) {
return x;
} else {
throw `${x}`;
}
};
const result1: r.Result<number, string> = rEager.tryCatch(
() => throwableFn(2),
(e: unknown) => `${JSON.stringify(e)}`
);
const resultFn = rLazy.tryCatch(
throwableFn,
(e: unknown) => `${JSON.stringify(e)}`
);
const result2: r.Result<number, string> = resultFn(2) // result1と同等のオブジェクト
例外を投げる可能性がある非同期関数をResult
型もしくはResult
型を返す関数に変換する関数です。
正格評価の場合、第一引数は引数を持たない非同期関数となります。
遅延評価の場合、第一引数は任意の引数を持つ非同期関数となります。
どちらの場合でも、例外発生時に実行される関数の引数の型は実装者が責任を持って設定する必要があります。
どのような例外が発生するか不明な場合は、引数の型をunknown
型に設定し、任意のTypedCause
型を返却することで例外を扱いやすくなります。
Note
この関数は厳密には
Promise
ではなくPromiseLike
を扱う関数です。 これはPrismaのPrismaPromise
クラスのような標準のPromise
を継承していないクラスも取り扱うためです。 しかし、この関数から返却されるオブジェクトがどのようなPromise
実装か型から判別できません。 そのため、Prismaのトランザクションのように非標準のPromise
に依存している処理は一つの関数にまとめた上で、tryCatchAsync
関数で覆うことを推奨します。 これにより明確にレイヤーを分割できる上、コードも簡略かされます。
import * as r from "https://esm.sh/@totto2727/result";
import * as rEager from "https://esm.sh/@totto2727/result/eager";
import * as rLazy from "https://esm.sh/@totto2727/result/lazy";
import * as remeda from "https://esm.sh/remeda@1.29.0";
const fn = (x: number) => `${x}`;
const fn2 = (x: number) => x + 1;
const throwableFn = async (x: number) => {
if (x % 2) {
return x;
} else {
throw `${x}`;
}
};
const result1: r.Result<number, string> = await rEager.tryCatchAsync(
async () => await throwableFn(2),
(e: unknown) => `${JSON.stringify(e)}`
);
const resultFn = rLazy.tryCatchAsync(
throwableFn,
(e: unknown) => `${JSON.stringify(e)}`
);
const result2: r.Result<number, string> = await resultFn(2) // result1と同等のオブジェクト
- Rust
- Result
- anyhow
- Swift
- 命名(JS標準の例外と重複することを避けるため)
- 実行環境
- Bun >1.0.0
- エディタ
- 現時点ではJetbrains IDEの設定のみ用意されています。
- 今後、VSCodeの設定ファイルを追加する予定です。
- Plugin
TODO
- このリポジトリをフォークする
- フォークしたリポジトリをローカルにクローンしてプロジェクトルートに移動する
- パッケージのインストール
bun i
- 開発する
- 変更内容を記述する
bun changeset
- コミット前
bun precommit
- コミット&プッシュ
- フォークしたリポジトリからプルリクエストを作成する
- 以下は管理者がバージョンアップする場合のみ
- バージョンの変更とプッシュ
bun changeset version
- mainブランチにマージする
- タグをつけてプッシュ
bun changeset tag
- CI/CDが自動でNPMに公開する