火曜日, 6月 3, 2025
- Advertisment -
ホームニューステックニュースTS で Effect System を作ってみた

TS で Effect System を作ってみた



実装はここ https://jsr.io/@mizchi/[email protected]

あらすじ

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

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

使い方

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

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

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
  
  "log",
  
  [message: string],
  
  void
>("log");
const val = defineEffect"val", [], number>("val");
type ProgramEffect = EffectFortypeof log> | EffectFortypeof val>;

async function* program(): AsyncGeneratorProgramEffect> {
  const v = yield* val(); 
  yield* log(`v:${v}`);
}

console.log(
  await Array.fromAsync(
    
    performAsync(program(), {
      
      [log.t]: async (message) => {
        console.log(`[log] ${message}`);
      },
      [val.t]: async () => {
        return 42;
      },
    })
  )
);


これの何が嬉しいか。

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

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

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

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

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

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

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

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

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


function* program(): GeneratorProgramEffect> {
  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(): Generatortypeof val> {
  const v = yield* val();
  return v;
}
async function* program(): AsyncGeneratorProgramEffect> {
  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: ResultStepProgramEffect>[] = [
    [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(): AsyncGeneratorEffectFortypeof a>> {
  yield* a();
  
  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
  EffectFortypeof a> | EffectFortypeof b> | EffectFortypeof c>
> {
  yield* a();
  yield* b();
}

async function* program(): AsyncGeneratorEffectFortypeof a>> {
  yield* a();
  
  yield* extendProgram(extended(), {
    [b.t]: () => {}, 
    [c.t]: () => {}, 
  });
}

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

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


async function* program(): AsyncGenerator
  EffectFortypeof a>,
  ExtendedEffectFortypeof extended>
> {}

参考

https://effect.website/

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



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -