Result 型を便利に使いたい!
最近 Domain Model Made Functional を読みました
これを TypeScript で表現してみたいけど、Railway Oriented はやりすぎじゃ...
って思った矢先に、Effect.gen で Rust みたく Result を使えるっぽい!?
純関数型的に表現するのを、ちょっと妥協したいなと思っていたので
Railway Oriented じゃない Result をどうやるのか見てみたいと思いました
そもそも Effect.gen って魔法みたいじゃないですか?
そもそも、型定義を見てもよくわからない...
僕が気になったのは以下の点です
- なぜ
yeildではなくてyield*なのか? - なぜ
Generatorなのに個々のyield*に対して型を推論できるのか?
では、実際に Effect.gen っぽい関数群を作ってみて、なぜなのか確かめてみましょう
実際に作ってみよう!
基本的な Result の定義
error: boolean で場合分けする Result の定義は、こんな感じになります
type Success<T,> = T & { error: false };
type Failure<F,> = F & { error: true };
type Result<T, F> = Success<T> | Failure<F>;
const toSuccess = <T,>(success: T) => ({ ... success, error: false }) as Result<T, never>;
const toFailure = <F,>(failure: F) => ({ ... failure, error: true }) as Result<never, F>;
const extractSuccess = <T,>(success: Success<T>) => { const { error, ... rest } = success; return rest; };
const extractFailure = <F,>(failure: Failure<F>) => { const { error, ... rest } = failure; return rest; };
gen の定義
上記の Result を利用する gen の定義は、このような感じになります
ここの型はそんなに重要ではないので unknown で逃げています
function gen<T, F>(definision: () => Generator<Result<unknown, NoInfer<F>>, Success<NoInfer<T>>, unknown>) {
const generator = definision();
let result = generator.next();
if (result.done) { return result.value; }
if (result.value.error) { return result.value; }
while (true) {
result = generator.next(extractSuccess(result.value));
if (result.done) { return result.value; }
if (result.value.error) { return result.value; }
}
}
// definition: () => Generator<Result<unknown, NoInfer<F>>, Success<T>, unknown>) として Success<T> は推論してもいいです
ユーティリティ関数の定義
ユーティリティとして Result<T, F> を受け取って T を受け取り
T をそのまま return する Generator を定義します
この関数がかなりのキモなポイントです
function* unwrap<T, F>(a: Result<T, F>): Generator<Result<T, F>, T, T> {
return yield a;
}
実際の利用例
使う側はこうやって Effect.gen と (ある程度) 同じメンタリティで使えます
キモは unrwap 側の generator に yield* で処理を委譲しているという点にあり、
これにより yeild* 毎に unwrap の結果を参照するため、型がその都度決まります
const result = gen((function* () {
const a = yield* unwrap(toSuccess({ hello: 'world', num: 5 } as const));
let z = a.num < Math.random() * 10 ? toSuccess({ y: 'test' }) : toFailure({ x: 'fail' });
const b = yield* unwrap(z);
return toSuccess(b);
});
// この result をよしなにと...
Pros/Cons
Pros
命令的に Result の Implicit な Early Return が表現できる
元々やりたいと思っていた、命令的な Early Return ができます
Result を剥がす操作で Result が Failure なら、ついでに Return してしまえます
これは便利だなと思いました
Cons
Result の型を事前に決める必要がある
今回は gen で NoInfer で明示的に型の推論をしないようにしています
これは Generator が yield や next が毎回同じ型であることを期待するためです
このため、事前に共通の型の情報を与えなければならず、
実際に起こりえるエラー型の直和型をコードから直接推論することができません...
ここら辺をうまくできる方法があったら知りたいです...
まとめ
Effect.gen がどうやって個々の型を推論しているのか謎でしたが
ステップバイステップで追っていくと、中心的な理屈がわかりました
ここで紹介した例は Generator でしたが、AsyncGenerator とすれば
非同期処理もまとめて扱えてお得になります
基本的には AsyncGenerator をラップすることになると思います
でも yield* を使う理由が型推論だけという理由なので、
あまりプロダクションコードに入れる気は (まだ) していないです...