🔖

フロントエンドの main() を合成関数として副作用を集約する

2023/05/27に公開

これは未実装のアイデアを含む記事です。(後述する lint rule が未実装です)

要は EffectSystem を作ろうとしました。 https://www.eff-lang.org/

void に意味を込めたい

こういうフロントエンドのコードについて考えてみましょう。

function mount(): void {
  const div = document.createElement('div');
  div.textContent = "hello";
  document.body.append(div);
}

function print(): void {
  console.log("hello");
}

function maybeError(): void {
  // 低確率で例外が起こる関数
  if (Math.random() > 0.999) {
    throw new Error("error");
  }
}

async function doSend(): Promise<void> {
  try {
    const res = await fetch("/post", {});
    const _data = await res.json();
    console.log(_data);
  } catch (err) {
    console.error(err);
  }
}

function sub() {
  maybeError();
  maybeError();
}

async function main() {
  mount();
  print();
  await doSend();

  sub();
}

// run
main()

ほとんどのアプリケーションは、複雑度は違いますが、だいたいこういう処理セットの組み合わせで成立しています。

これらの関数の問題、というか自分が持ってる課題感は、型を見ても何をしてるかが想像しづらい点です。アプリ全体で fetch を触ってるか否か、がわかりません。そう思ったことがない人は、まあそう思った人がいると思ってください。

中で起きるイベントを捕捉したい

main(): void は内部的に様々な副作用を持っています。例えば DOM を書き換えたり、外部に fetch したり、Console に出力したりしています。

JavaScript において、何をもって副作用とするかは曖昧ですが(例えば Console は副作用でしょうか?自分は人間のためにメモリをリークさせるので副作用だと思います)、 これを Haskell 等のモナドを持つ言語では、副作用を扱う一連の手続きを IO モナドと表現したりします。

IO モナドと副作用 - Haskell-jp

main() をIO モナドで考えると、単に返り値 void として捉えるのではなく、様々な副作用を合成した、合成関数と捉えることができそうです。

で、自分は minifier を作っていて、アプリ内で発生する副作用の総計をどうしても解析したい、という願望がありました。 返り値が例えば main の返り値は void ではなく、この副作用を含めた表現にできないか?という発想です。

というわけで、ランタイムに関与しない Opaque な Eff 型を定義して、 mount(): void & Eff<Operation.DOM> と型で表現してみることにしました。

// Type Utility
declare const __EFFECT_TYPE__: unique symbol;

const enum Operation {
  DOM,
}

type Eff<E extends Operation> = {
  readonly [__EFFECT_TYPE__]?: E;
};

function mount(): void & Eff<Operation.DOM> {
  const div = document.createElement('div');
  div.textContent = "hello";
  document.body.append(div);
}

これで、とりあえず DOM を触っている、という気持ちを宣言することができました。気持ちだけですが。

ここから頑張るなら、 関数の返り値に Eff<Operation.DOM> が含まれているときだけ DOM 操作が許される、という Lint Rule を書こうと思えば書けます。

それはあとで頑張るとして(本当か?)、まあ雑に書き足すぐらいには邪魔にならない宣言ができそうです。それに意味があるかというと、TypeScript 自体がよく考えたらランタイムに関与しないお気持ち表明器にすぎないので、意味はあると言い張ることはできると、自分は考えました。

副作用を集約したい。 全ては Generator だった

最初は EffectSystemによくある handle()perform() みたいな関数で抽象できないか考えてたんですが、やはり言語処理系の支援がないので無理がありました。

ところで、 JavaScript で忘れられがちな機能に Generator/AsyncGenerator 関数があります。

function * g() {
  yield 1;
  yield 2;
  yield 3;
}

for (const i of g()) {
  console.log(i); // 1, 2, 3
}

これはイテレータの内部実装などに使われる機能ですが、これは Generator<T> 型と表現されます。正確に言うと最後の ReturnType がとかいろいろあるんですが、一旦イテレータとして使う範囲だとこう表現できます。

さっき g() に型をつけるならこうですね。

function * g(): Generator<number> {
  yield 1;
  yield 2;
  yield 3;
}

つまり、先程の Eff<T> と組み合わせると、こう書いたものが自然に推論されるのでは?と思ってやってみました。

async function * main() {
  yield mount();
}

// run: ランタイムでは _eff はすべて void
for await (const _eff of main()) {}

これで main 関数の型は main(): AsyncGenerator<void & Eff<Operation.DOM>> と表現されます。

というわけで、副作用がありそうな型を全部 Eff<T> 付けて、最終的に Generator 関数で繋ぐことですべての副作用が main() の型として集約できるはずと思い、最初のコードにそれを当てはめてみることにします。

副作用を自動推論できる main() 関数

// Type Utility
declare const __EFFECT_TYPE__: unique symbol;

const enum Operation {
  DOM,
  Console,
  Fetch,
  PostMessage,
  Throwable
}

type Eff<E extends Operation> = {
  readonly [__EFFECT_TYPE__]?: E;
};

type AnyGenerator<T> = AsyncGenerator<T> | Generator<T>;

// main 関数から Operation の一覧を取り出す
type GetEffect<F extends AnyGenerator<any>> =
  F extends AnyGenerator<infer T> ? T extends Awaited<Eff<infer U>> ? Awaited<U> : never : never;

function mount(): void & Eff<Operation.DOM> {
  const div = document.createElement('div');
  div.textContent = "hello";
  document.body.append(div);
}

function print(): void & Eff<Operation.Console> {
  console.log("hello");
}

function maybeError(): void & Eff<Operation.Throwable> {
  if (Math.random() > 0.999) {
    throw new Error("error");
  }
}

async function doSend(): Promise<void & Eff<Operation.Fetch | Operation.Console>> {
  try {
    const res = await fetch("/post", {});
    const _data = await res.json();
    console.log(_data);
  } catch (err) {
    console.error(err);
  }
}

function * sub() {
  yield maybeError();
  yield maybeError();
}

async function * main() {
  yield mount();
  yield print();
  yield await doSend();

  yield * sub();
}

// run
for await (const _eff of main()) {}

// この型が最終的に発生した副作用の合計を表現する
export type MainOps = GetEffect<ReturnType<typeof main>>;

こうすると最後の MainOps 型は type MainOps = Operation.DOM | Operation.Console | Operation.Fetch | Operation.Throwable と推論されています。つまりこの main で抽象されるアプリケーションは DOM を触って、 Console に出力し、 Fetch を投げ、 例外を投げる、ということがわかります。

これで main 関数まですべてのお気持ち関数を Generator として繋ぐと、すべての副作用を集約できるはずです。

僕は今まで Generator はイテレーター作る専用ツールだと思っていたんですが、この発想を得て目からウロコが落ちる思いでした。

あとは頑張って Linter 書くだけですね。頑張りましょう。

https://www.typescriptlang.org/ja/play?target=8#code/CYUwxgNghgTiAEYD2A7AzgF3gfWwUQDEC8BhAFWzIE0AFPXALngFcUBLAR2YTQE8BbAEZIIAbgCwAKCnJ0WECmb94AeQAOIGFAxtU8AN5T48ACIqAsgBoj8EqjQiQ1ycYIgMYABbPjNJJnMQNDQoAHMnGzJPGCQAdyhBCBApAF8pKQxeDXg8ADNcgB48eBAADwwFYDRVDS0dVAA+eABeAxs4KGBUCF54AG1cQmJySlp6bABdAH4mPAlJFPmMrIQAQRReAHEFTW0kGAKyJtbVvhQwbZRdjH3DpoAfeEvr26OlyUzs7Yw83PAMAoEErlSrVdZbHZ1W5QDYNY42IFlCooKrwcHPKEHNgoP4weBHeBTfHA5Go1bxNgVYBFfIFbG4+AAVThhLRFKpBWZ8CYVwAbppufA+Zp3mU1PssJ8EOYoNj1NVWt9fv8CgAldzMGAoMgrApSpC5eD8WUoOHvXKsMD1FBGpCsDAACgAlExeUg2MB4AAyHK09TXXQoAB0ZnMTUMLkQ9iwwDYvJa8C6YCUCgwQbAHQqeCS-FTDoA5LHefmnfNjEWgxVynYUMisK0AESeEAQCBIBtlxNIZO52tB4TAXhBqBqDQoh1F0updKSC3na3wNQwbGOl3wN0e72+wr+uqBoM1hxJcM2WRHkBBtuhB1NltthtThYzudWwNGqC8QQgPAwGIwZ2uu6no+r8BS7to+5RDE8SJCAJ6RmwhoOjKGCeEGWgokg-DOvATQAAxBgAnMRTptJGxiodBQogLEOS-vsN6aH+D6dmkT7SJIUBnGA8AvguXQAMqVAB8A0DE-BsGgIAFBuwHbmBtQQagQZuB4njwI84HWge9iOCyEYUTAvQGcYUZyPAcAKvAUDsrx7heDeAD04qYA2lgGCkj6mWZmA4MA2hQAmNmylgllBgAVg4KDOp2xhno4l5INe2D+RgUBefAKSINoXjwA6TGkSZcW6UkQZMQxBWsdOHF8W+ABU8BoMwgg4SZvBsC2nrGp+370f+GXtZ1749T+f4xdVUhcbw5y8ZaC4Nca2KtTYg0QF1dq1uNkarZ6S4rltxg7dZtmCcJj4rR1a3wA1TUtY+bFSI5jkWawUi5Psx0hXlZ5YNgID5PABrvktTqFQ9khAA

Discussion