🧵

TS で Effect System を作ってみた

に公開2

実装はここ https://jsr.io/@mizchi/domain-types@0.0.8

あらすじ

https://zenn.dev/mizchi/articles/main-is-composite-function

https://zenn.dev/mizchi/articles/domain-modeling-by-generator

使い方

npm と jsr に公開しておいた。 Node/Deno どっちでも使える。

基本的な発想として、ある program の実行とは AsyncGenerator<Effect> あるいは Generator<Effect> のイテレータで表現できるとする。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator

program 定義とは別に、エフェクトの宣言とは別に対応するハンドラ(EffectHandler)を注入する。それが同期か非同期かはそれ自身ではなく、実行方式で決まる。

import {
  type EffectFor,
  defineEffect,
  performAsync,
} from "@mizchi/domain-types";

const log = defineEffect<
  // unique effect key
  "log",
  // parameter types
  [message: string],
  // return type
  void
>("log");
const val = defineEffect<"val", [], number>("val");
type ProgramEffect = EffectFor<typeof log> | EffectFor<typeof val>;

async function* program(): AsyncGenerator<ProgramEffect> {
  const v = yield* val(); // return by handler
  yield* log(`v:${v}`);
}

console.log(
  await Array.fromAsync(
    // AsyncGenerator
    performAsync(program(), {
      // run program with handlers
      [log.t]: async (message) => {
        console.log(`[log] ${message}`);
      },
      [val.t]: async () => {
        return 42;
      },
    })
  )
);
// [log] v:42
// => [ [ "val", [], 42 ], [ "log", [ "v:42" ], undefined ] ]

これの何が嬉しいか。

利点: 型による副作用の記述を強制する

AsyncGenerator<Effect> として使用するエフェクトを明示しないといけないので、型を見ると何を実装しないけといけないかわかる。

const log = defineEffect<"log", [message: string], void>("log");
async function* program(): AsyncGenerator<EffectFor<typeof log>> {
  yield* log(`hello`);
}

これは型シグネチャを見ることで、この関数が内部で発行するエフェクトが静的にわかる。
型シグネチャにないもの は yield できない。

また、実行時にはエフェクトに対してのハンドラを必ず要求する。

performAsync(program(), {
  [log.t]: async (message) => {
    console.log(`[log] ${message}`);
  },
});

利点: 実行時にハンドラが同期か非同期かどうかを決定

実行時の performperformAsync かでイベントハンドラの同期/非同期を決定できる。

performAsync は常に AsyncGenerator になるが、 Generator は perform で同期イテレータにできる。

// ...
function* program(): Generator<ProgramEffect> {
  const v = yield* val();
  yield* log(`v:${v}`);
}

console.log(
  Array.from(
    perform(program(), {
      [log.t](message) => {
        console.log(`[log] ${message}`);
      },
      [val.t]: () => {
        return 42;
      },
    })
  )
);

handler 側にすべての非同期があれば、 program 側は AsyncGenerator ではなく Generator になるはず。

何に使えるかと言うと、 program を分割した時に、同期実行部分を部分的にテストできる。

// ...
function* subProgram(): Generator<typeof val> {
  const v = yield* val();
  return v;
}
async function* program(): AsyncGenerator<ProgramEffect> {
  const v = yield* subProgram();
  yield* log(`v:${v}`);
}
const xs = [
  ...perform(sub(), {
    [val.t]: () => {
      return 42;
    },
  }),
];

Generator から AsyncGenerator は呼べないが、AsyncGenerator から Generator の yield* generator はできる。

利点: 内部トレーサビリティのあるテストを書ける

これを使うと、あるプログラムが内部的にどのような実行ステップを踏んだのかをデバッグできる。

import { none, performAsync, ResultStep, returns } from "@mizchi/domain-types";
import { query, log, ProgramEffect, read, timeout, write } from "./effects.ts";
import { program } from "./program.ts";
import { expect } from "@std/expect";

Deno.test("Sample App Program Test", async () => {
  const steps = await Array.fromAsync(
    performAsync(program(), {
      [log.t]: none,
      [read.t]: returns("mocked"),
      [write.t]: none,
      [timeout.t]: none,
      [query.t]: returns([]),
    })
  );
  const expected: ResultStep<ProgramEffect>[] = [
    [log.t, ["Starting complex workflow..."], undefined],
    [read.t, ["config.json"], "mocked"],
    [log.t, ["Config loaded: mocked"], undefined],
    [query.t, ["SELECT * FROM users"], []],
    [log.t, ["Found 0 users"], undefined],
    [timeout.t, [500], undefined],
    [write.t, ["report.txt", "Report: 0 users processed"], undefined],
    [timeout.t, [500], undefined],
  ];
  expect(steps).toEqual(expected);
});

工夫せずに実行ステップをすべて保持すると全部の実行履歴をもってしまいメモリが溢れてしまうが、これはイテレータとして実装してるので、Array.fromAsync 等で明示的に保持しない限りメモリを占有しない。

単にログを捨てて実行するならこれだけ。

const g = performAsync(program(), {
  [log.t]: none,
  [read.t]: returns("mocked"),
  [write.t]: none,
  [timeout.t]: none,
  [query.t]: returns([]),
});
for await (const _ of g) {
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync

欠点

const a = defineEffect<"a">("a");
const b = defineEffect<"b">("b");

async function* program(): AsyncGenerator<EffectFor<typeof a>> {
  yield* a();
  // @ts-expect-error 型エラーになるが実行されてしまう。
  yield* b();
}

あくまで型で縛ってるだけで、それを無視して実行はできる。TS でこれを縛るのは型の表現力が足りない。

TODO

型パズルとして、やりこんでみたが、まだやりたいことがある。

実行中の Effect を明示的に拡張したい。

// 未実装
declare function extendProgram(...args: any[]): any;

const a = defineEffect<"a">("a");
const b = defineEffect<"b">("b");
const c = defineEffect<"c">("c");

async function* extended(): AsyncGenerator<
  EffectFor<typeof a> | EffectFor<typeof b> | EffectFor<typeof c>
> {
  yield* a();
  yield* b();
}

async function* program(): AsyncGenerator<EffectFor<typeof a>> {
  yield* a();
  // これができるか?
  yield* extendProgram(extended(), {
    [b.t]: () => {}, // スコープを上書きする
    [c.t]: () => {}, // 拡張先に合わせて新規にハンドラーを追加
  });
}

Generator の実行時は this 自体が Generator インスタンスになってるので、これを引き回せばできそうな気はしてる。

が、型の表現が可能かが不明。また、その場合 yield * extendProgram(...) が拡張された側をどう扱うかが自明でない。

// こういう型定義にしたほうが良いかもしれない
async function* program(): AsyncGenerator<
  EffectFor<typeof a>,
  ExtendedEffectFor<typeof extended>
> {}

参考

https://effect.website/

https://github.com/susisu/effectful を大いに参考にして、EffectRegistry を引いて AsyncGenerator 用に拡張した結果がこれです。

Discussion

あいや - aiya000あいや - aiya000

no-implicit-effects-in-effのようなeslint-rulesを作って

欠点

や、Generator<EffectFor<typeof notPrint>>console.log()などの任意の副作用を抑制できれば、実用してもいいレベルに上がりそう!

(いや後者は、副作用を持つ任意の計算が既知じゃないと検知できないから、むりか⋯。さもなくば純粋な関数も検知しちゃう。これがTypeScriptやScalaの限界⋯。)