Promiseを捨てよ、Thenableを抱け:`PromiseLike
TypeScriptの型システムとV8ランタイムのイベントループの境界線において、我々が日々何気なく記述している `async/await` や `Promise` という抽象化は、時にパフォーマンスの足枷となり、また過剰な型制約という名の防壁を生み出す。
一般的な入門書は「非同期関数には `Promise
本稿では、`PromiseLike
—
1. `Promise` と `PromiseLike` の決定的な乖離
まず、TypeScriptの標準ライブラリ(`lib.es5.d.ts` 等)に定義されている `PromiseLike
interface PromiseLike
then
onfulfilled?: ((value: T) => TResult1 | PromiseLike
onrejected?: ((reason: any) => TResult2 | PromiseLike
): PromiseLike
}
このインターフェースの美しさは、「それが `Promise` クラスのインスタンスである必要はない」という点に宿る。必要なのは `then` メソッドのシグネチャを満たしていること、すなわち「Thenableであること」のみだ。
構造的型付け(Structural Subtyping)の罠と恩恵
TypeScriptは公称型(Nominal)ではなく構造的型付けを採用している。したがって、ネイティブの `Promise` は内部的に複雑な状態マシン(Pending, Fulfilled, Rejected)や V8 特有の非同期トレースバッファを伴うが、`PromiseLike
これが何を意味するか。無駄な `Promise` インスタンスの生成(Allocations)を回避し、メモリプレッシャーを極限まで下げるための極めて強力な武器となるのだ。
—
2. 実践:`PromiseLike` を極めた非同期パイプラインの構築
では、実際のコードベースにおいて、どのように `PromiseLike
以下のコードは、ネイティブ `Promise`、サードパーティのカスタムThenable、そして同期的な値をすべて同一のパイプラインで安全に処理する高階関数の実装例である。
/
- 任意の非同期・同期ソース(PromiseLike または 直値)を受け取り、
- 型安全に変換処理を適用する高次元パイプライン関数
/
type AsyncSource
/
- 厳密な型推論を維持しつつ、thenableを安全に伝播させるディスパッチャー
/
defun
source: AsyncSource
transform: (value: T) => AsyncSource
): Promise {
// 実行時におけるオーバーヘッドを最小化するため、
// すでにthenを持つオブジェクトか、あるいはプレーンな値かを判定せず
// Promise.resolveのネイティブ最適化パスに委ねる
return Promise.resolve(source).then(transform);
}
一見すると単純なラッパーに見えるかもしれない。しかし、コンパイラの型推論の観点において、ここには深い意味がある。もし引数の型を `Promise
// 悪臭を放つアンチパターン:同期的値やカスタムThenableを無理やりネイティブPromiseで包んでいる
const val = 42;
myFunction(Promise.resolve(val)); // 不要なマイクロタスクの生成
`PromiseLike
—
3. コンパイラの型評価と「無限再帰型」の回避
`PromiseLike
以下のコードを見てほしい。
// 危険:深すぎるネストや循環参照に対してコンパイラがクラッシュ(あるいはDepth Limitに到達)する恐れがある
type DeepPromiseLike
T extends object
? { [K in keyof T]: DeepPromiseLike
: T;
TypeScriptの型チェッカーは、型の深さに制限(通常は50階層程度)を設けている。`PromiseLike` を再帰的に解決しようとすると、コンパイル時間が指数関数的に増大し、最終的に `Type instantiation is excessively deep and possibly infinite.(TS2589)` エラーを引き起こす。
これを防ぐための、プロフェッショナルな型ガードのテクニックが以下だ。
/
- 安全にアンラップを行うための条件付き型定義
- 再帰の深度を制御し、不必要な型展開を抑制する
/
type FlattenThenable
export function resolveDeeply
return Promise.resolve(input) as any;
}
このアプローチでは、ランタイムの実行効率を落とさずに、コンパイラに対する型の負担を最小限に抑え込んでいる。
—
4. V8ランタイムとイベントループの低レイヤ挙動
なぜ、ここまでして `Promise` ではなく `PromiseLike` にこだわるのか?
その答えは、JavaScriptエンジン(V8など)におけるマイクロタスクキュー(Microtask Queue)の消費メカニズムにある。
V8のイベントループにおいて、ネイティブの `Promise` が解決されると、それは内部のホスト環境(Node.jsやブラウザ)のマイクロタスクキューにエンキューされ、C++レイヤでの最適化パス(PromiseJobs)を通る。
しかし、独自の `PromiseLike` 実装(例えば、軽量なCancellationトークンを持つオブジェクトや、メモリー効率を重視したカスタムDeferred)を用いる場合、`then` メソッドの振る舞いを完全に制御できる。
// 超軽量なカスタムThenableの例(ゼロアロケーションに近いカスタム実装)
class MicroscopicThenable
constructor(private readonly executor: (resolve: (value: T) => void) => void) {}
then
onfulfilled?: ((value: T) => TResult1 | PromiseLike
onrejected?: ((reason: any) => TResult2 | PromiseLike
): PromiseLike
// ネイティブPromiseを使わず、直接コールバックを評価することで
// V8のマイクロタスクキューのオーバーヘッドをバイパスする極限の最適化
try {
let resolvedValue: any;
let isFulfilled = false;
this.executor((val) => {
resolvedValue = val;
isFulfilled = true;
});
if (isFulfilled && onfulfilled) {
return {
then: (next) => next ? next(onfulfilled(resolvedValue)) : ({} as any)
};
}
} catch (e) {
if (onrejected) onrejected(e);
}
return this as any;
}
}
このような極限の低レイヤ最適化を行ったオブジェクトであっても、関数の型定義が `Promise
ここで `PromiseLike
—
結び:型はランタイムの枷ではなく、芸術のためのキャンバスである
多くのプログラマーは、TypeScriptの型システムを「バグを防ぐためのガードレール」としか捉えていない。しかし、アーキテクトの視点において、型とは「ランタイムの挙動を正確に記述し、極限までパフォーマンスを引き出すためのメタ言語」である。
`PromiseLike
フレームワークが用意した既製の型に安住せず、言語の仕様の深淵を覗き込め。そこに真のエンジニアリングの領域が広がっている。