TypeScriptでファイナルエンコーディングを使って非同期処理を後乗せする

2023/10/08に公開

最初のプログラム

非同期処理ってプログラムの中に一度入り込むとそれに関与する一帯が非同期に“感染”してしまって面倒なことになりますよね。

例えば以下のシンプルなプログラムを見てみましょう。

function main(): void {
  const text = readUtf8File("input.txt");
  logWithTimestamp("ファイルを読み込みました。");
  writeUtf8File("output.txt", `${text},${text}`);
  logWithTimestamp("ファイルを書き込みました。");
}

これはテキストファイルを読み込んで文字列を取得し、その文字列を2個複製してカンマ , で繋げてファイルを書き込むプログラムです。

main 関数は以下の小さな3つのヘルパー関数から成り立ちます。

function readUtf8File(path: string): string {
  // 実装
}

function writeUtf8File(path: string, text: string): void {
  // 実装
}

function logWithTimestamp(msg: string): void {
  // 実装
}

この input.txt が存在する状態でプログラムを実行すると、例えば以下のような実行結果が得られます。

【実行結果】

[41:30.421] ファイルを読み込みました。
[41:30.424] ファイルを書き込みました。
input.txt
test
output.txt
test,test

至ってシンプルです。

【コード全文】

import fs from "node:fs";

function readUtf8File(path: string): string {
  return fs.readFileSync(path, "utf8");
}

function writeUtf8File(path: string, text: string): void {
  fs.writeFileSync(path, text);
}

function logWithTimestamp(msg: string): void {
  const now = new Date();
  const minutes = now.getMinutes().toString().padStart(2, "0");
  const seconds = now.getSeconds().toString().padStart(2, "0");
  const milliseconds = now.getMilliseconds().toString().padStart(3, "0");
  console.log(`[${minutes}:${seconds}.${milliseconds}] ${msg}`);
}

function main(): void {
  const text = readUtf8File("input.txt");
  logWithTimestamp("ファイルを読み込みました。");
  writeUtf8File("output.txt", `${text},${text}`);
  logWithTimestamp("ファイルを書き込みました。");
}

main();

テストしやすくする

ファイルシステムや標準入出力にアクセスせずに main 関数のテストを行いたいとします。
これを行うためのシンプルな方法は、後で実装を取り替えたい処理そのものを引数に移動してしまうことです。

export function main(
  readUtf8File: (path: string) => string,
  writeUtf8File: (path: string, text: string) => void,
  logWithTimestamp: (msg: string) => void,
): void {
  const text = readUtf8File("input.txt");
  logWithTimestamp("ファイルを読み込みました。");
  writeUtf8File("output.txt", `${text},${text}`);
  logWithTimestamp("ファイルを書き込みました。");
}

従来通りファイルシステムや標準入出力にアクセスして処理を行うプログラムを実行するには、main 関数に今までと同じ処理を引数に与えます。

main(readUtf8File, writeUtf8File, logWithTimestamp);

ファイルシステムにアクセスしなくてもよい場合、例えばプログラムロジックのテストを行いたい場合にはファイルシステムをバイパスするような処理を与えて実行することができます。

test.ts
import assert from "node:assert/strict";

// 注:main関数もインポートする

function readUtf8FileFake(path: string): string {
  assert.equal(path, "input.txt");
  return "テスト用テキスト";
}

function writeUtf8FileFake(path: string, text: string): void {
  assert.equal(path, "output.txt");
  assert.equal(text, "テスト用テキスト,テスト用テキスト");
}

function createLogWithTimestampFake(): (msg: string) => void {
  let callCount = 0;
  const expected = ["ファイルを読み込みました。", "ファイルを書き込みました。"];
  return (msg) => {
    if (expected.length <= callCount) {
      assert.fail();
    } else {
      assert.equal(msg, expected[callCount++]);
    }
  };
}

const logWithTimestamp = createLogWithTimestampFake();

main(readUtf8FileFake, writeUtf8FileFake, logWithTimestamp);

素朴に非同期処理化する

先ほどのコードでは fs.readFileSyncfs.writeFileSync など、同期的なメソッドを使って処理を行っています。
しかし、これはいわゆるブロッキングIOであり、非同期的に処理を行うことが推奨されます。
そこで、node:fs/promises を使ってプロミスベースに書き直しましょう。

これにより、main 関数の引数である2つの処理の型が以下のように変わります。

- readUtf8File: (path: string) => string;
+ readUtf8File: (path: string) => Promise<string>;
- writeUtf8File: (path: string, text: string) => void;
+ writeUtf8File: (path: string, text: string) => Promise<void>;

main 関数でこれらの非同期的関数を呼ぶ必要があるので、main 関数自体も“プロミス化”する必要があります。
結果としてコード全体は以下のように書き換えられます。

import fs from "node:fs/promises";

function readUtf8File(path: string): Promise<string> {
  return fs.readFile(path, "utf8");
}

function writeUtf8File(path: string, text: string): Promise<void> {
  return fs.writeFile(path, text);
}

function logWithTimestamp(msg: string): void {
  const now = new Date();
  const minutes = now.getMinutes().toString().padStart(2, "0");
  const seconds = now.getSeconds().toString().padStart(2, "0");
  const milliseconds = now.getMilliseconds().toString().padStart(3, "0");
  console.log(`[${minutes}:${seconds}.${milliseconds}] ${msg}`);
}

export async function main(
  readUtf8File: (path: string) => Promise<string>,
  writeUtf8File: (path: string, text: string) => Promise<void>,
  logWithTimestamp: (msg: string) => void,
): Promise<void> {
  const text = await readUtf8File("input.txt");
  logWithTimestamp("ファイルを読み込みました。");
  await writeUtf8File("output.txt", `${text},${text}`);
  logWithTimestamp("ファイルを書き込みました。");
}

main(readUtf8File, writeUtf8File, logWithTimestamp);

さて、これだけでは終わりません。
main 関数の型が変わってしまったので、先ほど書いたテストコードのコンパイルが通らなくなっています。
テストコードも非同期化しなければなりません。

import assert from "node:assert/strict";

// 注:main関数もインポートする

async function readUtf8FileFake(path: string): Promise<string> {
  assert.equal(path, "input.txt");
  return "テスト用テキスト";
}

async function writeUtf8FileFake(path: string, text: string): Promise<void> {
  assert.equal(path, "output.txt");
  assert.equal(text, "テスト用テキスト,テスト用テキスト");
}

function createLogWithTimestampFake(): (msg: string) => void {
  let callCount = 0;
  const expected = ["ファイルを読み込みました。", "ファイルを書き込みました。"];
  return (msg) => {
    if (expected.length <= callCount) {
      assert.fail();
    } else {
      assert.equal(msg, expected[callCount++]);
    }
  };
}

const logWithTimestamp = createLogWithTimestampFake();

main(readUtf8FileFake, writeUtf8FileFake, logWithTimestamp);

これでコンパイルが通るようになり、テストを実行できるようになりました。
しかし、ファイルシステムに一切アクセスしない上記のテストコードを、一体なぜ非同期化しなければならないのでしょうか?

ファイナルエンコーディング

そこで、非同期処理を後付けする機構を作ります。

最終結果が型 T の値であるようなプログラムを表す型 Cmd<T> を考えます。
もし、このような機構を作ることができれば、例えば Cmd<number> は同期的に数を計算するプログラムとなります。
Cmd<Promise<number>> は非同期的に数を計算プログラムとなります。

後はそのような機構をどのように作るかです。
そこで、“自由なんとやら”のファイナルエンコーディングとして良く知られているテクニックを使います。

Cmd<T> の値とは、以下に示すような runCmd メソッドを持つオブジェクトであるとします。

export type Cmd<T> = {
  runCmd: <R>(visitor: {
    kreturn: (value: T) => R;
    kread: (path: string, k: (text: string) => Cmd<T>) => R;
    kwrite: (path: string, text: string, k: (dummy: void) => Cmd<T>) => R;
    klog: (msg: string, k: (dummy: void) => Cmd<T>) => R;
  }) => R;
};

runCmd メソッドは Cmd<T> 値を任意の型 R に変換する処理で、kreturn, kread, kwrite, klog の4つの関数を引数にとります。
kreturn はプログラムの最終結果 value: T を受け取り、それを任意の型 R に変換します。
残りの kread, kwrite, klog 関数はそれぞれ main 関数の引数である readUtf8File, writeUtf8File, logWithTimestamp に対応する関数です。

上記の Cmd<T> 型の定義は変換元となる関数から導き出すことができます。
まず、kread, kwrite, klog 関数の返り値の型はいずれも R です。
更に、これらの関数は基本的には対応元の関数と同じ型の引数を持ちますが、引数の最後に k と名付けられた追加の引数を持ちます。
k の入力の型は対応元の関数の返り値の型(の中身の型)で、返り値の型はいずれも Cmd<T> です。

なお、k は継続(keizoku)と読みます。

以上の法則を念頭に置くと、原理が分からなくとも機械的に Cmd<T> の定義を書くことができます。

ヘルパー関数を書く

次に、Cmd<T> 値を作るためのヘルパー関数を作成しましょう。
これも機械的に作成することができます。

まず最初に作るのは、プログラムの最終結果 value: T を直接与えることで Cmd<T> を作る関数です。
Cmd<T> は最終結果が型 T の値であるようなプログラムを表す型となることを意図して定義されているので、この関数の存在は自明です。
これを行うには、以下のように runCmd メソッドを持つオブジェクトを作成し、継続 kreturnvalue: T を渡します。

function returnCmd<T>(value: T): Cmd<T> {
  return {
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kreturn(value);
    },
  };
}

次に“継続なんとやら”と呼ばれることがある Cont<T, R> 型を定めます。

type Cont<T, R> = (k: (value: T) => R) => R;

誤解を恐れずに言えば、Cont<T, R> は(今すぐに利用できるとは限らないが)型 T の値を(いずれは)利用できる値の型です。
これは継続 k を引数にとり、値が利用できるようになればそれを継続に渡します。

さて、readUtf8File, writeUtf8File, logWithTimestamp と同じ引数を持つヘルパー関数を作ります。
型の唯一の違いは、返り値の型を Cont<X, Cmd<T>> とすることです。
ここで、型 X は変換元の関数の返り値の型(の中身の型)です。

実装はやはり runCmd メソッドを持つオブジェクトを作って返します。
引数は個数と型が一致するようにします。
まず、readUtf8File に対応する関数を見てみましょう。

function readCmdCont<T>(path: string): Cont<string, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kread(path, k);
    },
  });
}

なぜこの関数が readUtf8File と同じ引数を持つのか、そして Cont<string, Cmd<T>> を返すのでしょうか。
それは、この関数が中で readUtf8File 相当の処理を行うことを意図されているからです。
つまり、readUtf8File 関数を呼び出せなければならないので、同じ引数を持っていなければなりません。
では、返り値の型はなぜ Cont<string, Cmd<T>> なのでしょうか。
それは、readUtf8File の結果として得られるはずの文字列が今すぐに利用できるとは限らないからです。

残り2つの関数についてもヘルパーを用意します。

function writeCmdCont<T>(path: string, text: string): Cont<void, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kwrite(path, text, k);
    },
  });
}

function logCmdCont<T>(msg: string): Cont<void, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return klog(msg, k);
    },
  });
}

ミニプログラムを作る

さて、ヘルパー関数を作れたのでミニプログラムを作成します。
継続として既に作成した returnCmd: (value: T) => Cmd<T> を渡すことで以下の3つの基本的なプログラムが得られます。

function readCmd(path: string): Cmd<string> {
  return readCmdCont<string>(path)(returnCmd);
}

function writeCmd(path: string, text: string): Cmd<void> {
  return writeCmdCont<void>(path, text)(returnCmd);
}

function logCmd(msg: string): Cmd<void> {
  return logCmdCont<void>(msg)(returnCmd);
}

Cmd<T> は最終結果が型 T の値であるようなプログラムを表す型となるようことを意図して定義されていることを思い出してください。
つまり、readCmd 関数はファイルパスを指定するとその内容の文字列を最終結果とするプログラムを返します。
writeCmd, logCmd も同様にその意図を言葉で説明することができます。

ここで重要なのは、変換元となった関数、例えば readUtf8File: (path: string) => Promise<string> とは異なり、readCmd 関数は文字列を取得する操作が同期的であるべきか非同期的であるべきかには特別言及していないという点です。

プログラムを組み合わせる

次に、2つのプログラムを組み合わせる方法を考えます。
例えば、ファイル取得操作を行うプログラムがあるとします。
これは文字列を取得するプログラムなのでその型は Cmd<string> です。
その取得結果を例えば input という名前の変数に代入したとします。
もちろん、変数に格納した値はプログラムの後の行で利用することができます。
つまり、以降の行は input: string を使って書かれたプログラムです。
最終的にプログラムが T 値を返すとすると、その型は Cmd<T> で表せます。
この動作はファイル取得操作の結果に応じて変わるので、すなわち input: string の関数です。
このように、最終的に T 値を返すプログラムの途中にファイル取得操作があるとき、これをプログラム Cmd<string> とプログラム作成関数 (input: string) => Cmd<T> に分割して考えることができます。

このことの逆を考えてみましょう。
一般に、プログラム Cmd<A> と、その最終結果 value: A を利用するプログラム作成関数 (value: A) => Cmd<B> を組み合わせると、全体として1つの大きなプログラム Cmd<B> を得られるはずです。
このような関数は伝統的に bind と呼ばれます。
さっそく実装してみましょう。

function bind<A, B>(ma: Cmd<A>, f: (a: A) => Cmd<B>): Cmd<B> {
  return ma.runCmd({
    kreturn(value) {
      // 実装
    },
    kread(path, k) {
      // 実装
    },
    kwrite(path, text, k) {
      // 実装
    },
    klog(msg, k) {
      // 実装
    },
  });
}

最も単純なのが kreturn です。
プログラム ma: Cmd<A> の最終結果 value: A を利用できるので、これを f に渡すだけで新しいプログラムを得られます。

    kreturn(value) {
      return f(value);
    },

次に、kread を考えてみましょう。
まず、ファイルパス path を使って cont: Cont<string, Cmd<B>> を得ます。

    kread(path, k) {
      const cont: Cont<string, Cmd<B>> = readCmdCont(path);
      // 実装
    },

cont は継続 (value: string) => Cmd<B> を受け取って新しいプログラム Cmd<B> を返す関数です。

    kread(path, k) {
      const cont: Cont<string, Cmd<B>> = readCmdCont(path);
      return cont((value: string) => /* 実装 */);
    },

value: string が利用できるのはなぜでしょうか?
それはもちろん、ファイルのテキストが継続を介して利用可能になっているはずだからです。
これを引数の継続 k に渡すことでプログラム ma2: Cmd<A> が得られます。

    kread(path, k) {
      const cont: Cont<string, Cmd<B>> = readCmdCont(path);
      return cont((value: string) => {
        const ma2: Cmd<A> = k(value);
        // 実装
      });
    },

mama2 はどちらも Cmd<A> 型の値です。
これらの違いは何でしょうか?
それは単純で、ma はファイル読み込み処理前のプログラム、ma2 はファイル読み込み処理後のプログラムを表しています。
それでは、mama2 が同じ Cmd<A> 型の値なのはなぜでしょうか?
これも単純な理由で、プログラムが最終的に返す結果の型は不変だからです。
プログラムを順次実行しているだけで、プログラム中の命令が変わるわけではありません。

さて、mama2 の違いで重要なのは、ma2 のほうが残処理(プログラムの残り行数)が1つ少ないということです。
そこで、bind 関数を再帰呼び出しします。

    kread(path, k) {
      const cont: Cont<string, Cmd<B>> = readCmdCont(path);
      return cont((value: string) => {
        const ma2: Cmd<A> = k(value);
        return bind(ma2, f);
      });
    },

これで kread を実装できました。
以下のようにワンライナーで書き直しておきます。

    kread(path, k) {
      return readCmdCont<B>(path)((value) => bind(k(value), f));
    },

さて、この関数を実装するのは少し大変でしたが、実は残りの関数は同じパターンで実装できることが分かります。

function bind<A, B>(ma: Cmd<A>, f: (a: A) => Cmd<B>): Cmd<B> {
  return ma.runCmd({
    kreturn(value) {
      return f(value);
    },
    kread(path, k) {
      return readCmdCont<B>(path)((value) => bind(k(value), f));
    },
    kwrite(path, text, k) {
      return writeCmdCont<B>(path, text)((value) => bind(k(value), f));
    },
    klog(msg, k) {
      return logCmdCont<B>(msg)((value) => bind(k(value), f));
    },
  });
}

bind 関数が実装できたので、再帰呼び出しを正当化しておきましょう。
先ほど mama2 の違いについて述べた通り、kread, kwrite, klog のいずれの分岐においても、ma よりも再帰呼び出しの k(value) のほうが残処理が1つ少ないため、再帰呼び出しを繰り返していくといずれプログラムの最終処理(すなわち kreturn の分岐)に到達します。
したがって、この再帰呼び出しは停止します。

メインプログラムを作る

ミニプログラム readCmd, writeCmd, logCmd と、それらを組み合わせる方法 bind が揃ったので、ようやく main 関数相当のプログラムを書くことができます。
main プログラムの同期版を再掲載します。

export function main(
  readUtf8File: (path: string) => string,
  writeUtf8File: (path: string, text: string) => void,
  logWithTimestamp: (msg: string) => void,
): void {
  const text = readUtf8File("input.txt");
  logWithTimestamp("ファイルを読み込みました。");
  writeUtf8File("output.txt", `${text},${text}`);
  logWithTimestamp("ファイルを書き込みました。");
}

これに対応するプログラムは以下の通りです。

// prettier-ignore
export const mainCmd: Cmd<void> =
  bind(readCmd("input.txt"), (text) =>
  bind(logCmd("ファイルを読み込みました。"), () =>
  bind(writeCmd("output.txt", `${text},${text}`), () =>
       logCmd("ファイルを書き込みました。"),
  )));

まず、main の返り値の型が void なので、プログラムの型としては Cmd<void> に対応します。
次に、readUtf8File などのパラメータ化した処理は readCmd 等のミニプログラムの呼び出しに対応します。
これらミニプログラムの断片は bind 関数を用いて繋ぎ合わせることができます。
readCmd による計算結果は bind 関数の第2引数に渡すコールバック関数の引数として利用できます。
たったこれだけです。

プログラムを非同期的に実行する

最後に、作成したプログラムを実行するための関数を作ります。
runCmd メソッドを呼び、それぞれの分岐に対応する実装を記述します。
これは main 関数の引数に処理を渡すことに相当します。

function runAsync<T>(cmd: Cmd<T>): Promise<T> {
  return cmd.runCmd({
    async kreturn(value) {
      // 実装
    },
    async kread(path, k) {
      // 実装
    },
    async kwrite(path, text, k) {
      // 実装
    },
    async klog(msg, k) {
      // 実装
    },
  });
}

kreturn 分岐は単純で、プログラム cmd: Cmd<T> の最終結果が value: T であることを利用できるのでそれを返すだけです。

    async kreturn(value) {
      return value;
    },

kread 分岐では、まず本命のファイルの読み込みを行ってテキストを文字列として取得します。

    async kread(path, k) {
      const text = await fs.readFile(path, "utf-8");
      // 実装
    },

次に、読み込み結果を継続 k に渡すことで新しいプログラム cmd2 を得ます。
処理を1行進めただけなので最終結果の型は不変、すなわち Cmd<T> のままです。

    async kread(path, k) {
      const text = await fs.readFile(path, "utf-8");
      const cmd2 = await k(text);
      // 実装
    },

cmd2cmd よりも残処理が少ないので、runAsync 関数を再帰呼び出しします。

    async kread(path, k) {
      const text = await fs.readFile(path, "utf-8");
      const cmd = await k(text);
      return await runAsync(cmd);
    },

kwrite, klog 分岐も同様に実装します。

function runAsync<T>(cmd: Cmd<T>): Promise<T> {
  return cmd.runCmd({
    async kreturn(value) {
      return value;
    },
    async kread(path, k) {
      const text = await fs.readFile(path, "utf-8");
      const cmd = await k(text);
      return await runAsync(cmd);
    },
    async kwrite(path, text, k) {
      await fs.writeFile(path, text, "utf8");
      const cmd = await k();
      return await runAsync(cmd);
    },
    async klog(msg, k) {
      console.log(msg);
      const cmd = await k();
      return await runAsync(cmd);
    },
  });
}

後はこれを用いてプログラムを実行するだけです。

runAsync(mainCmd);
ここまでのコード
import fs from "node:fs/promises";

export type Cmd<T> = {
  runCmd: <R>(visitor: {
    kreturn: (value: T) => R;
    kread: (path: string, k: (text: string) => Cmd<T>) => R;
    kwrite: (path: string, text: string, k: (dummy: void) => Cmd<T>) => R;
    klog: (msg: string, k: (dummy: void) => Cmd<T>) => R;
  }) => R;
};

function returnCmd<T>(value: T): Cmd<T> {
  return {
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kreturn(value);
    },
  };
}

type Cont<T, R> = (k: (value: T) => R) => R;

function readCmdCont<T>(path: string): Cont<string, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kread(path, k);
    },
  });
}

function writeCmdCont<T>(path: string, text: string): Cont<void, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return kwrite(path, text, k);
    },
  });
}

function logCmdCont<T>(msg: string): Cont<void, Cmd<T>> {
  return (k) => ({
    runCmd({ kreturn, kread, kwrite, klog }) {
      return klog(msg, k);
    },
  });
}

function readCmd(path: string): Cmd<string> {
  return readCmdCont<string>(path)(returnCmd);
}

function writeCmd(path: string, text: string): Cmd<void> {
  return writeCmdCont<void>(path, text)(returnCmd);
}

function logCmd(msg: string): Cmd<void> {
  return logCmdCont<void>(msg)(returnCmd);
}

function bind<A, B>(ma: Cmd<A>, f: (a: A) => Cmd<B>): Cmd<B> {
  return ma.runCmd({
    kreturn(value) {
      return f(value);
    },
    kread(path, k) {
      return readCmdCont<B>(path)((value) => bind(k(value), f));
    },
    kwrite(path, text, k) {
      return writeCmdCont<B>(path, text)((value) => bind(k(value), f));
    },
    klog(msg, k) {
      return logCmdCont<B>(msg)((value) => bind(k(value), f));
    },
  });
}

// prettier-ignore
export const mainCmd: Cmd<void> =
  bind(readCmd("input.txt"), (text) =>
  bind(logCmd("ファイルを読み込みました。"), () =>
  bind(writeCmd("output.txt", `${text},${text}`), () =>
       logCmd("ファイルを書き込みました。"),
  )));

function runAsync<T>(cmd: Cmd<T>): Promise<T> {
  return cmd.runCmd({
    async kreturn(value) {
      return value;
    },
    async kread(path, k) {
      const text = await fs.readFile(path, "utf-8");
      const cmd2 = await k(text);
      return await runAsync(cmd2);
    },
    async kwrite(path, text, k) {
      await fs.writeFile(path, text, "utf8");
      const cmd2 = await k();
      return await runAsync(cmd2);
    },
    async klog(msg, k) {
      const now = new Date();
      const minutes = now.getMinutes().toString().padStart(2, "0");
      const seconds = now.getSeconds().toString().padStart(2, "0");
      const milliseconds = now.getMilliseconds().toString().padStart(3, "0");
      console.log(`[${minutes}:${seconds}.${milliseconds}] ${msg}`);
      const cmd2 = await k();
      return await runAsync(cmd2);
    },
  });
}

runAsync(mainCmd);

同期的にテストする

ファイルシステムにアクセスしないテストの話に戻ります。
このテストは非同期的である必要がありません。
この場合、Cmd<T>T に変換するだけです。

import assert from "node:assert/strict";
// 注:Cmd<T>とmainCmdもインポートする

function createLogWithTimestampFake(): (msg: string) => void {
  let callCount = 0;
  const expected = ["ファイルを読み込みました。", "ファイルを書き込みました。"];
  return (msg) => {
    if (expected.length <= callCount) {
      assert.fail();
    } else {
      assert.equal(msg, expected[callCount++]);
    }
  };
}

const logWithTimestamp = createLogWithTimestampFake();

function runTestSync<T>(cmd: Cmd<T>): T {
  return cmd.runCmd({
    kreturn(value) {
      return value;
    },
    kread(path, k) {
      assert.equal(path, "input.txt");
      const cmd2 = k("テスト用テキスト");
      return runTestSync(cmd2);
    },
    kwrite(path, text, k) {
      assert.equal(path, "output.txt");
      assert.equal(text, "テスト用テキスト,テスト用テキスト");
      const cmd2 = k();
      return runTestSync(cmd2);
    },
    klog(msg, k) {
      logWithTimestamp(msg);
      const cmd2 = k();
      return runTestSync(cmd2);
    },
  });
}

runTestSync(mainCmd);

説明しきれなかったこと

  • Visitorパターン
  • “自由なんとやら”や“継続なんとやら”とは何か
  • “自由なんとやら”の本来の定式化
  • “自由なんとやら”をエンコーディングする別の方法
  • DIとの違い
  • インターフェースの交叉型を利用したボイラープレートの削減方法
  • 存在型を用いてジェネリック関数を扱う話

反響があれば頑張って書くかもしれません。

まとめ

  • TypeScriptを使って“自由なんとやら”が示唆する構造を手動で作成した
  • この方法が上手くいく理由の直感的な説明を行った
  • 同じ“プログラム”を非同期的に本実行したり、同期的にテスト実行したりできる機構を作成した

もし本記事が参考になりましたら、いいねあるいはコメントよろしくお願いします。

GitHubで編集を提案

Discussion