TypeScript でエフェクトシステムを再現したい
tskaigi で susisu さんの Generator で Promise ランタイムを作る発表をみて、昔作ったやつがもっとやれそうな気がしたので、やってみた話。
やりたいこと
TS の言語システムが物足りなくて、ドメインを表現しきれない。とくに副作用を持つ関数に、なんとかして副作用の型を宣言したい。
過去に、 Async Generator でこれができるのを確認した。
function print(): void & Eff<Operation.Console> {
console.log("hello");
}
async function* main() {
yield print();
}
for await (const _eff of main()) {
}
// この型が最終的に発生した副作用の合計を表現する
export type MainOps = GetEffect<ReturnType<typeof main>>; // Operation.Console
とはいえ、この路線だと発想に限界がある。それもあって最近 haskell の effect system 勉強してきた。
関数に副作用のシグネチャがついてることより、代数的データ型で実装と副作用を分離するほうが大事そうだったので、最終的にはこれができるようにした。
type ProgramEffect = PrintEff | DelayEff | NetworkEff;
function* program(): Generator<ProgramEffect, number> {
yield print("Start");
yield delay(500);
yield print("End");
yield* subTask();
const v = yield* runFetchTask();
yield print(`HTTP GET result: ${v.ok}: ${v.value}`);
return v.value;
}
// run with handlers
const handlers = defineHandlers<ProgramEffect>({
print(payload) {
console.log(`print ${payload}`);
},
delay: async (ms) => {
await new Promise((resolve) => setTimeout(resolve, ms));
},
network: async (init) => {
return Promise.resolve({
ok: true,
value: 42,
});
},
});
const result = await performResult(program(), handlers);
console.log("Program result:", result);
この記事ではこれを実装するまでのテクニックを段階的に解説する。
(が、もっといい方法があるなら誰か教えてほしい)
Generator の yield * 使い方の確認
-
async function * ()
で関数を宣言すると、その返り値の型はAsyncGenerator<T, R, N>
になる - T: イテレータが返す型
- R: 最後に Return される型
- N: イテレータが呼ばれる側で
.next(v)
で渡され、yield
式自体の値になる
Generator 内で yield *
すると、
async function* g() {
yield 1;
yield 2;
yield 3;
return 4;
}
async function* f() {
yield 0;
const r = yield* g();
yield r;
yield 5;
}
async function main() {
for await (const v of f()) {
console.log(v);
}
}
await main();
/**
0
1
2
3
4
5
*/
Generator をつなげた時の TNext
-
for await(const v of g)
でイテレータを回すときは Next は必ず空(undefined
) になる - generator を
yield *
で合成した際の.next()
は合成されている
async function* f(): AsyncGenerator<number, void, number> {
const ret = yield 1;
console.log("[next]", ret);
const ret2 = yield 2;
console.log("[next]", ret2);
}
async function main() {
const handler = (v: any) => {
console.log("[handler]", v);
return v * 2;
};
const gen = f();
let next = await gen.next();
while (!next.done) {
const result = handler(next.value);
next = await gen.next(result);
}
}
await main();
/**
[handler] 1
[next] 2
[handler] 2
[next] 4
*/
perform で共通のハンドラーを通した値を yield の結果として受け取りたい
- Generator で yield したものに対して、共通の Handler を通して処理させたい。
- とりあえず全部 number とする
async function* g(): AsyncGenerator<number, void, number> {
const x = yield 7;
console.log("[g]", x);
const y = yield 11;
console.log("[g]", y);
}
async function* f(): AsyncGenerator<number, void, number> {
const ret = yield 1;
console.log("[f]", ret);
const ret2 = yield 2;
console.log("[f]", ret2);
yield* g();
}
async function perform(
generator: AsyncGenerator<number, void, number>,
handler: (value: number) => Promise<number>
) {
let next = await generator.next();
while (!next.done) {
const result = await handler(next.value);
next = await generator.next(result);
}
}
const handler = async (v: number) => {
console.log("[handler]", v);
return v * 2;
};
await perform(f(), handler);
/**
[f] 2
[handler] 2
[f] 4
[handler] 7
[g] 14
[handler] 11
[g] 22
*/
つまり、yield する値を代数的データ型にすれば、親コンテキストのハンドラーに解決するようにできれば、擬似的なエフェクトシステムっぽくなる。
こういうふうにしたい。
type Eff = {
eff: "getValue";
};
async function* main(): AsyncGenerator<Eff, void, number> {
// ここを型推論できるか?
const v = yield { eff: "getValue" };
console.log(ret2);
}
perform(main(), {
// getValue の注入は実行時
getValue() {
return 42;
},
});
代数的データっぽく複数のエフェクトをハンドルしたい
type GetValueEff = {
eff: "getValue";
payload: undefined;
};
type DoubleEff = {
eff: "double";
payload: number;
};
type Eff = DoubleEff | GetValueEff;
// 最終的に実装するハンドラー
type HandlerMap = {
double: (payload: number) => Promise<number>;
getValue: () => Promise<number>;
};
// Handler を通るはずの yield の結果(TNext)を Return に入れ替える
// Eff => T は Handler 側で行われる前提で、かなり弱い制約。
// any to number の cast になっている
async function* double(payload: number): AsyncGenerator<DoubleEff, number> {
return yield {
eff: "double",
payload,
};
}
async function* getValue(): AsyncGenerator<GetValueEff, number> {
return yield {
eff: "getValue",
payload: undefined,
};
}
async function* f(): AsyncGenerator<Eff, void, any> {
const v = yield* getValue();
console.log("[val]", v);
const ret = yield* double(v);
console.log("[f]", ret);
}
async function perform(
generator: AsyncGenerator<Eff, void>,
handlers: HandlerMap
) {
let next = await generator.next();
while (!next.done) {
const result = await handlers[next.value.eff](next.value.payload as any);
next = await generator.next(result);
}
}
async function main() {
const handlers: HandlerMap = {
double: async (payload: number) => {
console.log("[double handler]", payload);
return payload * 2;
},
getValue: async () => {
console.log("[getValue handler]");
return 42; // 固定値を返す
},
} as const;
await perform(f(), handlers);
}
await main();
// 42
// 84
最終的にアプリケーションコードとなるのはここ
async function* f(): AsyncGenerator<Eff, void, any> {
const v = yield* getValue();
console.log("[val]", v);
const ret = yield* double(v);
console.log("[f]", ret);
}
TS だと yield に対して、それを解決した値を柔軟に記述する方法がないが、 return yield
で yield 結果を return にすりかえるというテクニックで無理矢理解決する。
TS だとたぶん、ここを型安全に書く方法は現状ない気がする。
最終形: @mizchi/domain-types
(このリポジトリは Result 型とかも実装してるので、厳密には AsyncGenerator だけではない)
さっきの例を拡張して、与えられた Handler を型推論可能になる型パズルを perform に実装した。
基本的には return yield
で Generator の型をすり替えるのを採用。結局誰かがハンドラの型を一度は書かないといけないので、そういうものとする。
import {
type Eff,
defineHandlers,
defineEff,
performResult,
} from "@mizchi/domain-types";
type PrintEff = Eff<"print", string>;
type DelayEff = Eff<"delay", number>;
type NetworkEff = Eff<"network", { url: string }>;
const print = defineEff<"print", string>("print");
const delay = defineEff<"delay", number>("delay");
const doFetch = defineEff<"network", { url: string }>("network");
// effect with return value
function* runFetchTask(): Generator<
NetworkEff,
{ ok: boolean; value: number }
> {
return yield doFetch({
url: "https://example.com/api/data",
});
}
function* subTask(): Generator<PrintEff, void> {
yield print("a");
yield print("b");
// @ts-expect-error type mismatch but it works
yield delay(100);
}
type ProgramEffect = PrintEff | DelayEff | NetworkEff;
function* program(): Generator<ProgramEffect, number> {
yield print("Start");
yield delay(500);
yield print("End");
// yield* waitFor(subTask());
yield* subTask();
// const v = yield* waitFor(httpGetTask());
const v = yield* runFetchTask();
yield print(`HTTP GET result: ${v.ok}: ${v.value}`);
return v.value;
}
{
// run with handlers
const handlers = defineHandlers<ProgramEffect>({
print(payload) {
console.log(`print ${payload}`);
},
delay: async (ms) => {
await new Promise((resolve) => setTimeout(resolve, ms));
},
network: async (init) => {
return Promise.resolve({
ok: true,
value: 42,
});
},
});
const result = await performResult(program(), handlers);
console.log("Program result:", result);
}
出力
print Start
print End
print a
print b
print HTTP GET result: true: 42
Program result: {
ok: true,
value: 42,
steps: [
{ eff: "print", payload: "Start" },
{ eff: "delay", payload: 500 },
{ eff: "print", payload: "End" },
{ eff: "print", payload: "a" },
{ eff: "print", payload: "b" },
{ eff: "delay", payload: 100 },
{
eff: "network",
payload: { url: "https://example.com/api/data" }
},
{ eff: "print", payload: "HTTP GET result: true: 42" }
]
}
内部で発生したエフェクトをトレース可能になるので、デバッグしやすい。
エフェクトの呼び出しと実装を分離できて、内部で発生した effect 型を外側から確認できるようになる。
また自分が yield できない関数は型チェック違反になるので、明示的に副作用を型で記述できる。
// subTask は delay を yield できないので、型レベルでは呼び出せない
// ただし、ランタイムではバリデーションできないので、厳密ではない
function* subTask(): Generator<PrintEff, void> {
yield print("a");
yield print("b");
// @ts-expect-error type mismatch but it works
yield delay(100);
}
難点としては、他で見たことがない形式なので、自分でも書くのがしんどい。おそらく近いのは Effect TS
他のエフェクトシステムはエフェクト宣言で型をスコープレベルで制御できるようになっているものが多いが、TS だとそこまでメタプロができない。
例えば koka だとこう。
effect yield
ctl yield( i : int ) : bool
fun traverse( xs : list<int> ) : yield ()
match xs
Cons(x,xx) -> if yield(x) then traverse(xx) else ()
Nil -> ()
fun print-elems() : console ()
with ctl yield(i)
println("yielded " ++ i.show)
resume(i<=2)
traverse([1,2,3,4])
例えば koka だと型システムそのものがエフェクトで拡張されていて、呼び出し時にスコープレベルのいずれかの段階で解決すればいいことになっている。(それを静的解析できる)
感想
エフェクトの宣言は型スコープと値スコープを同時に複数いじらないといけないので、素の TS だとやはり限界がある。(知ってた)
とはいえ JS の標準的な表現で寄せつつ、TS 自体の拡張あるいは ES の仕様拡張で、将来的に実用可能な表現に辿り着けそうな気はしてきた。今の API 体系でも慣れの問題で使えなくはない。
Discussion
これはすごい!!
個人的にはExtensible EffectsやEffect Monadよりも、KokaのAlgebraic Effectsだと思いました(最後に書かれていますが。)
でもEffがユニオンで合成可能なので、Effect Monadっぽくもある。
型推論さえできればExtensible Effectsにもなれそう。
(これはおそらく無理でしょうが⋯うう。)
Generator<SomeEff, T>に、どうやって実際のSomeEffのhandlerが渡って、実行できているのかがわかりませんでしたが、コードを読めばわかりそう。
これはかなりいいライブラリだと思いました。
「TypeScriptプログラマーは、原始的な型付きJavaScriptプログラミングしか愛さない」ということを考えなければ⋯!!