TSKaigi 2025 での登壇「君だけのオリジナル async / await を作ろう」のスピーカーノートと補足です.
(TSKaigi 2025 自体の感想や面白かった発表の話なんかはまた別途書きます.)
スピーカーノート & 補足
「君だけのオリジナル async / await を作ろう」というタイトルで発表します. susisu ですよろしくおねがいします.
今日, 特に最後の方は多少抽象度が高い話題を扱うので, もしかしたらちょっと難しい話になってしまうかもなんですが, 最終的に私が一番伝えたいのは「TypeScript 面白い」ということなので, 気楽に聞いていただけたら幸いです.
改めましてこんにちは susisu です. 株式会社はてなというところでエンジニアをしています. 趣味は TypeScript の限界を攻めることで, 例えば型レベルインタプリタとか, ちょっと言語名が放送禁止なのでここでは詳しくは言えないんですけど, なんかいろいろやってます.
仕事では toitta という発話分析を行う SaaS を TypeScript で作っています. 最近は Prisma が Branded Types に対応して id みたいなのが互いに区別できるようになると嬉しいな〜と思ってます.
あと弊社毎年サマーインターンシップもやっておりまして, オフィスとリモートのハイブリッドという形で今年もやりますので, もし学生の方でご興味のある方がいらっしゃいましたらぜひ参加ご検討いただけたらと思います. まだ 6/9 まで応募受け付けています.
さて, 今日話すことなんですが, 話題としては大きく 4 つあります.
まず 1 個目 JavaScript の世界から始まりまして, 今日の発表のタイトルの通り, ジェネレータを使って async / await を自作する方法を紹介します. えっじゃあこれで発表終わりじゃんって感じなんですが, まあこれは古典的なテクニックで特に新規性もない話題ですし,
なにより今日は TSKaigi なので, 次にその自作の async / await に TypeScript で型をつけるにはどうするかという話をします. ここはちょっとしたテクのご紹介という感じですね.
続いて応用編ということで, 仮に async / await が自作できても, もう元からそこに async / await があるんだから特に面白くないですよね. ということで同じテクニックを Promise 以外のデータ, 具体例として Result 型でも使ってみようという話をします.
そして最後に, これは単なる私個人の趣味活動の紹介という感じなんですが, より一般化した話題としてエフェクトというものを TypeScript で扱ってみよう, ちゃんと型をつけてみよう, という試みを紹介します.
それでは早速 1 つめ. ジェネレータを使った async / await の作り方を紹介します. ちなみにこの話題どれくらい知っている方いますか?
(当日手が上がったのは 0.5 〜 1 割くらい)
0.5 割くらいですね, スライド作ってきてよかったです.
まず async / await のおさらいですが... 別におさらいも必要ないですかね. もはや現代において Promise を扱う際は必要不可欠と言えるんじゃないかと思います.
そんな async / await ですが, 最初からあったわけではなく, Promise の標準化から async / await の標準化までは 2 年くらいタイムラグがあったんですよね. その間人々はどうしていたかというと, トランスパイラを使って先取りしてたりもしたんですが, 一部, 特にサーバーサイドかな? ジェネレータを使って async / await っぽいコードを書くのがちょっとだけ流行ったりしました. tj/co や Koa とかですね.
補足: この当時はまだ IE なんかも全然使われていて, クライアントサイドにおいては async / await の代わりにジェネレータを使ったところでどのみちトランスパイルが必要な状況でした. なのであえて async / await ではなくジェネレータを使って書こうというモチベーションが生まれたのは, ちょうど Node.js v4 が出たりしてトランスパイルなしで ES2015 の機能が使えるようになったサーバーサイドが主だったのではないかなと思います.
そのジェネレータなんですが, 今ではあんまり使う機会がない機能としてお馴染みです. 最近もちょっと話題になってましたね.
下のコードがそれなんですが, こういう感じに function*
っていうのでジェネレータ関数を定義して,
その中で yield
ってのを書いてあげると, その値が順番に出てくるようなイテレータが作れます.
で, これの面白いところとしては, これって実は計算を中断したり再開できるコルーチンと見ることもできるんですね.
具体的にどういうことかというと, 例えばこういう感じにジェネレータ関数を定義したとします.
このジェネレータ関数を呼び出すとどうなるかなんですが, まだ呼び出しただけでは関数内の計算は実行されません.
そこでジェネレータの next()
メソッド (これはイテレータ一般のメソッドでもあるんですが) を呼び出してやると計算が開始されて, そして yield
で中断されます.
ここで yield
に与えた値が next()
メソッドの戻り値として得られていますね.
さらに next()
メソッドを呼び出すと計算が進んでいくんですが,
このとき next()
メソッドに引数を与えてやると, それが yield
式の値になります.
ここでは引数に与えた 1
がジェネレータ関数内の変数 x
に入っているということですね.
そして最後まで辿り着くと無事 done: true
ということで関数の戻り値が返ってきます.
この中断・再開できるという性質を利用すると, async / await を再現できそうです.
というのも await
というのは Promise が完了するまで関数の実行を中断して待つことにほかならないからです.
基本的なアイデアはここに書いたように, ジェネレータは Promise を yield
して計算を中断して,
その Promise が完了したら外側からジェネレータを再開してあげるという感じです.
async / await との対応関係はこういう感じです.
async function
が function*
, await
が yield
になったのと,
呼び出す時にはここでは run()
っていう, あらかじめ用意した,
いい感じに Promise をハンドリングしてジェネレータを再開してくれる関数を使うことにします.
となると, あとはその良い感じの関数 run()
を定義してあげるだけですね.
で, ここから実装を紹介するんですが, スライド中では全コードは紹介しきれないので, 完全版のコードをこちらのリポジトリに置いているのでよければ参考にしてください. この QR コードはしばらく右上に出しておきます.
では早速 run()
の実装なんですが,
まずいきなりなんですが, ジェネレータから yield
された Promise が成功したときの処理からはじめます.
この場合は comp
(これは残りの計算という意味で computation の略ですが, これがジェネレータです) を, Promise から得られた値 value
で再開してあげるとよさそうです.
で, 計算を再開すると, ジェネレータ関数からは 3 通りの応答があり得ます.
throw
して終了, return
して終了, yield
して中断のいずれかですね.
これらをそれぞれいい感じにハンドリングしてやります. throw
なら Promise.reject()
でエラー終了, return
なら Promise.resolve()
で正常終了という感じです.
yield
の場合が重要で, then()
を呼び出しているんですが, これで Promise の完了を待って, 再度この関数 onFulfilled()
か, Promise 失敗時のための onRejected()
を呼び出してあげます.
続いてその失敗時のための関数 onRejected()
なんですが,
onFulfilled()
との差分はこの計算を再開する部分だけです.
実はジェネレータの再開には next()
の他に throw()
というのもあって, これを呼び出すと yield
の位置でエラーが throw
されます
これはジェネレータ関数内に try ... catch
を書いていたときにちゃんと仕事をしてもらうために必要です.
ここまできたらあとは計算を開始するだけで,
onFulfilled()
を引数なしで呼び出してやれば, ジェネレータの計算が先頭から開始されます.
で下のようにジェネレータ関数を定義して実行すると Promise が得られるというわけですね.
以上で完成です.
ここまでのまとめです. ジェネレータは中断・再開できる計算, コルーチンとして見ることができます. これを使えば, ここまで見てきた通り, async / await 相当のものを自分で再実装できます.
これでめでたしめでたしと言いたいところなんですが, ここまではまだ JavaScript の話です. 実はここまでのものを TypeScript に持っていっても, そのままではうまく型がつけられません.
ということで TypeScript で型をつける方法を見ていきましょう.
まずは目指すべきところを確認しておきます.
今回目指すのはジェネレータ関数内の yield
式に正常な型がつけられる状態です.
run()
の実装内部の完全な型付けは行いません.
たぶんどうやっても any
が残ると思います.
さて yield
式に型付けしていきたいわけですが, ここには 2 つの壁があります.
1 つは, ジェネレータが必ず決まった方法で実行される保証がないこと,
もう 1 つは, yield
式に対しては式ごとに異なる型が付けられないことです.
まず 1 つめ, 決まった方法で実行される保証がないということなんですが,
例えばジェネレータを先ほどのように run()
関数を使って実行すればちゃんと動くわけですが,
一番下の行のように, 適当な方法で実行してしまうと, 全然正しく動きません.
この場合には x
とか y
に undefined
が入ってしまいます.
で, これの解決策なんですが,
特に有効な解決策がありません! なのでそういう規約ということにしてしまいます. React コンポーネントが単なる関数だからといって単なる関数として呼び出す人がいないのと同じように, わざわざこうする人も滅多にいないでしょう. 必要になったら Linter を作ることもできるんじゃないかなと思います.
ということで 1 つ目は解決したということにして... 2 つ目の yield 式ごとに異なる型が付けられないという話に進むのですが, これはにまずジェネレータの型についての説明が必要です.
ジェネレータ関数に対する型付けは, 一番下のコードのように Generator
というのが戻り値の型になるわけなんですが.
この Generator
には 3 つの型引数があります.
まず一個目の T
は, yield
に与える値の型です.
続いて TReturn
は return
に与える値の型, つまり戻り値の型ですね.
で最後の TNext
が next()
メソッドの引数の型であり,
ジェネレータの再開の挙動を思い出すとこれが yield
式の型ということになります.
これってつまり yield
式の型が TNext
一種類しかないということなんですね.
これがどう困るかというと, 例えば下のコードでいうと x
と y
の型が同じということになってしまいます.
一般には当然これらは別の型であってほしいですよね.
そこで解決策として登場するのが yield*
式です.
この yield*
式とは何かというと, Iterable をジェネレータ関数内で展開するための構文です.
delegate とも呼ばれますね.
ここで重要なのは yield*
式の型は yield
式とは異なり, 与えた Iterable の型に応じて決まるということです.
具体的には, Iterable
の方も Generator
と同じように 3 つ型引数があるんですが,
yield*
式の型はこのうち 2 つめの SReturn
と書いているものになります.
これはつまり yield*
式は式ごとに異なる型を持てるということですね.
なるほどこれで解決という感じなんですが,
一方 yield*
式を使うためには, 与える値が Iterable でないといけません.
なので yield
を単純に yield*
に置き換えれば良いかというとそんなことはなく, なんらか Iterable に変換してやる必要があります.
またこの Iterable への変換もなんでも良いわけではなく,
置き換え後も元々の yield
と同じ動きをしてほしいわけです.
とはいえその変換がどんなものかというと別に難しいわけではなくて,
単一の値を yield
して, その yield
式の値を return
するだけのジェネレータを作ってやれば解決します.
ここでは Promise という文脈を考慮して waitFor
という名前にしていますが,
こうすることで元々の yield
式と同じ動作で, 型情報だけが増えた状態を作れます.
ちなみにここで Comp<T>
というのを定義していますが, これはこの後もジェネレータの型として使用します.
Comp
というのは先ほどもありましたが computation, 計算の略です.
ここでの any
は最初に話した「決まった方法で実行される保証がない」ということに由来するものなので一旦無視してください.
まあ「型を破ろう」ということで...
で, 最後に run に対して型を外からつけてあげれば完成です.
これでこの例のように x
と y
が異なる型を持てるようになって,
目標にしていた TypeScript による型付けが完成しました.
一旦ここまでのまとめです.
ジェネレータによる自作 async / await に型をつけるためには 2 つ壁がありました.
一個はジェネレータの呼ばれ方に保証がないこと, もう一個は yield
式ごとに異なる型がつけられないことでした.
前者は規約化することでカバーしつつ, 後者は yield
の代わりに yield*
を使うことで乗り切れることがわかりました.
という感じで async / await が実装できましたが, 今更 async / await を実装しても特に嬉しくないですよね? ということで応用編に移っていきましょう.
ここまでに紹介したテクニックなんですが, お察しの通り Promise 以外に対しても使えます.
どんな時に使えるかというと, 具体的にはジェネレータから yield
された値と, ジェネレータの残りの計算を, なんらか上手にくっつけることができたらよいわけです.
この「上手に」って部分がミソではあるんですが, ちょっと今日は話している時間がなさそうなので省略します.
要するにモナドのことです.
Promise の場合は then()
メソッドがこの上手にくっつける役割を持っています.
補足: ジェネレータには同じ箇所からは一度しか再開できない (one-shot) という制約もあるので, すべてのモナドに対してジェネレータで糖衣構文を提供できるわけではないことに注意. 例えば同じ箇所から複数再開を要求するリストモナドのようなものに対しては適用できません.
例えば Result 型もこの条件に当てはまります.
Result 型というのは, ここに書いたような Ok
または Err
になるような型ですね.
同期版の Promise と考えればそんなに不思議ではないかもしれません.
Promise でいう then()
に対応するのは, 下に書いたようなエラーがあれば中断するような条件分岐です.
ここで改めて Promise の場合を振り返ってみると,
then()
を呼び出していた部分が await
あるいは yield*
に置き換えられたわけです.
これと同じように, Result 型の場合は左のようなエラーハンドリングのための条件分岐を,
右のように yield*
で置き換えて簡略化することができます
まあ Go 言語とかに慣らされていると左のコードに対して特に何の感情も湧かないかもですけど...
まあそれさておき.
補足: 私自身は慣らされている側なので, むしろ左の方が単純で良いこともあるとは思っています.
実装は Promise の場合とほぼ同じなので割愛しますが,
Result 型に対してもやはりいい感じに run()
関数を定義できて,
先程のような頻繁にエラーハンドリングを行うコードを簡略化できるわけです.
ちなみにおまけとして, 言語組み込みの Promise とは異なり, Result 型は自分で定義することも可能です.
とすると Result 型自体を先ほど説明したような Iterable にしておくことができます.
こうしておくと yield*
式を使うときに, Promise の時に行ったような変換を手で書かなくてもよくなります.
便利ですね.
さてここまでをまとめると, ジェネレータを使ったテクニックは Promise 以外にも応用が可能です. ここでは一例として Result 型の場合を紹介しましたが, 他にも例えば neverthrow や Effect といったライブラリは, 同じような方法でジェネレータを使った簡便な記法を提供していますし, それ以外ではパーサコンビネータなどでも同様のテクニックが使えたりします.
補足: ちなみに neverthrow は err
のときにジェネレータ関数内の try ... catch
が動かない実装になっていて, おいおい... と思わなくもないです.
とはいえジェネレータ関数からは任意の値がエラーとして throw
できてしまうので, try ... catch
を動かしつつエラーに厳密に型を付けるのは結構面倒だったりするんですよね.
ということでもう一つ応用例の紹介です.
ここでいうエフェクトというのは, 先ほど挙げたライブラリの Effect の話ではなく, その名前の由来となっている方で, プログラムを実行したときに発生する様々な効果, 例えば画面への表示やファイルの読み書きなどのことです. こうしたエフェクトは不用意に発生させると怪我をすることもあるので, 上手に取り扱いたいというわけです.
例えばネットワークリクエストであれば, セキュリティ的な理由でプログラムのどの部分から行われるのか制御したかったりであるとか, テストでは外部に影響がないようにモックで置き換えたいといった感じですね. こういったことを行うための仕組みはエフェクトシステムみたいに呼ばれたりします.
で, 具体的にコードがどういった雰囲気になるかなんですが...
例えばなんですが, もし TypeScript でこういったプログラムが書けるとしたらどうでしょうか?
ちょうど async function
の戻り値の型が Promise で非同期処理であるとマークされるように,
ここでは発生し得るエフェクトの種類でマークされています. ここでは net
= ネットワークと fs
= ファイルシステムですね.
もしここで net
がない中でネットワークリクエストを行うようなエフェクトを発生させると,
コンパイルエラーになってくれます.
こうなっていると net
が明示されていないプログラムではネットワークリクエストが行われないであろう, ということが型だけ見れば分かるわけです.
そしてこれらのエフェクトが具体的に何を行うかは, このプログラムの実行時に決めることができます. 例えば通常は実際にネットワークリクエストやファイルシステムへの書き込みを行いつつ, テストではモック実装に差し替えるなどです.
というのができたらどうでしょう? まあ, 結論としては,
できました.
実験的なライブラリを作ってみました. 今紹介したのは全てこのライブラリを使って実際に動作するコードです. 理論的には Algebraic Effects & Handlers と呼ばれるパラダイムになっています.
このライブラリの実装も, 基本的にはここまでの Promise や Result 型の場合の延長線上にあります.
下のジェネレータの型定義を見ていただけるとわかるとおり, ほぼ同じ形をしています.
ただし具体的な Promise
や Result
の部分は, エフェクト Effect
という形で抽象化しておいて,
後から実行する箇所で決められるようになっています.
補足: この Eff<T, Row>
の理論的な背景については以前の記事を参考.
この実装については話したいことがたくさんあるんですが, 時間が足りないです!
例えば実装に使ったテクニックではユニオン型でエフェクトの row (種類のことです) を素直に表現できること (TypeScript 以外の言語だとこれだけで 1 トピックになるようなものなんですが) とか,
interface
の宣言マージを使った高階型でエフェクトを抽象化しているとか,
さらに GADT で抽象化したエフェクトの戻り値の型を固定していることとか,
あとこれを使って具体的に何をするのか? 使えるのか? とか...
この後の Ask the Speaker や懇親会などでお話しできたら嬉しいです.
補足: 夢のある話ばかりしていてもアレなので「使えるのか?」という部分について補足しておくと, 実際にこれが使い物になるかはまだまだわからないことばかりです.
セキュリティ的な目的では現状は全く不十分で, 例えば直接 fetch()
や fs.writeFile()
を書かれてしまうと, 上で書いたような型による制約は一切効かずにすり抜けてしまいます.
また単純に抽象化という目的 (DI など) では, その抽象化コストに見合う効果が得られるのか, 他の方法と比べてどうかなど実用面での検証が足りていません.
助けて!
ということで全体のまとめに移ります.
まず最初に, ジェネレータ関数を中断・再開できる計算と捉えて, async / await 相当の記法を自作する方法を紹介しました.
そして, それに対して TypeScript で型を付ける方法を紹介しました.
yield*
を使うようにして, そのために値を Iterable に変換してあげるとよいです.
そして Promise 以外にも同じテクニックを応用する方法と, その一例を紹介しました. これでみなさんいつでもオリジナルの実装を作ろうと思えば作れますよね? もし作れるようになっていたらうれしいです.
最後により抽象化・一般化した話題として, Algebraic Effects というパラダイムを実現できることを紹介しました. こんなことまでできちゃう TypeScript って面白いと思いませんか? 私は面白いと思っています. といったところで以上で発表を終わります. ありがとうございました.
参考文献
登壇後の会話でこういうのどうやって勉強すると良いんでしょう? という話があったので, この発表ができるまでに読んだりしたものをいくつか挙げておきます. とはいえ私は体系的に学んだわけではないのと, ずっとうっすら考え続けているという感じなので, もはや覚えていないところも多いですが...
- 他の言語を見てみたり
- いろいろブログを読んでみたり
- そろそろFreeモナドに関して一言いっとくか - モナドとわたしとコモナド
- Freeモナドを超えた!?operationalモナドを使ってみよう - モナドとわたしとコモナド
- Coyoneda って…… お前 functor がデータ構造になっただけやんけ!! - blog.ryota-ka.me
- Algebraic Effects for the Rest of Us — overreacted
- Algebraic Effectsとは? 出身は? 使い方は? その特徴とは? 調べてみました! - lilyum ensemble
- Algebraic EffectsとExtensible Effectsの違いってなんや? 関係あんの? - lilyum ensemble
- 周辺分野 (?) の論文を読んでみたり
- Data types à la carte
- Typed Tagless Final Interpreters
- Hefty Algebras: Modular Elaboration of Higher-Order Algebraic Effects (結局高階エフェクトについてはまだちゃんと検討していない)
- 手を動かして考えてみたり