【テクニカル・上級編】関数型における「PromiseLike」を用いた非同期関数の柔軟な型定義 – TypeScript コア・型システムの基礎解析バイブル

Promiseを捨てよ、Thenableを抱け:`PromiseLike`で極限化する非同期関数型設計の深淵

TypeScriptの型システムとV8ランタイムのイベントループの境界線において、我々が日々何気なく記述している `async/await` や `Promise` という抽象化は、時にパフォーマンスの足枷となり、また過剰な型制約という名の防壁を生み出す。

一般的な入門書は「非同期関数には `Promise` を使え」と説く。しかし、シニアエンジニアやミドルウェアのコア開発者領域において、その教条主義は悪手である。ネイティブの `Promise` インスタンスだけでなく、RxJSのObservable、独自のゾーン管理を持つカスタムthenable、あるいは別プロセスのV8アイソレート間で共有される代理オブジェクトに至るまで、「非同期的に解決される値」をシームレスに呑み込むためには、コンパイラレベルで `PromiseLike` を掌握しなくてはならない。

本稿では、`PromiseLike` を用いた柔軟かつ堅牢な関数型定義の極限を、コンパイラの型評価メカニズムとV8のマイクロタスクキューの挙動の双方から解き明かす。

—

1. `Promise` と `PromiseLike` の決定的な乖離

まず、TypeScriptの標準ライブラリ(`lib.es5.d.ts` 等)に定義されている `PromiseLike` の本質を確認する。

interface PromiseLike {
then(
onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null
): PromiseLike;
}

このインターフェースの美しさは、「それが `Promise` クラスのインスタンスである必要はない」という点に宿る。必要なのは `then` メソッドのシグネチャを満たしていること、すなわち「Thenableであること」のみだ。

構造的型付け(Structural Subtyping)の罠と恩恵

TypeScriptは公称型(Nominal)ではなく構造的型付けを採用している。したがって、ネイティブの `Promise` は内部的に複雑な状態マシン(Pending, Fulfilled, Rejected)や V8 特有の非同期トレースバッファを伴うが、`PromiseLike` を要求する関数に渡されるオブジェクトは、単に `then` プロパティを持つプレーンなオブジェクトであってもコンパイルを通過する。

これが何を意味するか。無駄な `Promise` インスタンスの生成(Allocations)を回避し、メモリプレッシャーを極限まで下げるための極めて強力な武器となるのだ。

—

2. 実践:`PromiseLike` を極めた非同期パイプラインの構築

では、実際のコードベースにおいて、どのように `PromiseLike` を駆使すべきか。
以下のコードは、ネイティブ `Promise`、サードパーティのカスタムThenable、そして同期的な値をすべて同一のパイプラインで安全に処理する高階関数の実装例である。

/

  • 任意の非同期・同期ソース(PromiseLike または 直値)を受け取り、
  • 型安全に変換処理を適用する高次元パイプライン関数

/
type AsyncSource = T | PromiseLike;

/

  • 厳密な型推論を維持しつつ、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` を受け入れるインターフェースであれば、呼び出し側は無駄な `Promise.resolve()` を呼ぶ必要がなくなり、コンパイラもまた、不必要な型アサーションのバイパスを許容しない。

—

3. コンパイラの型評価と「無限再帰型」の回避

`PromiseLike` を深くネストしたデータ構造(例えば、ツリー状の非同期評価ノードなど)に適用する場合、TypeScript開発者が陥りがちな致命的な罠が「無限再帰型(Infinite Recursive Types)」である。

以下のコードを見てほしい。

// 危険:深すぎるネストや循環参照に対してコンパイラがクラッシュ(あるいはDepth Limitに到達)する恐れがある
type DeepPromiseLike =
T extends object
? { [K in keyof T]: DeepPromiseLike } | PromiseLike>
: T;

TypeScriptの型チェッカーは、型の深さに制限(通常は50階層程度)を設けている。`PromiseLike` を再帰的に解決しようとすると、コンパイル時間が指数関数的に増大し、最終的に `Type instantiation is excessively deep and possibly infinite.(TS2589)` エラーを引き起こす。

これを防ぐための、プロフェッショナルな型ガードのテクニックが以下だ。

/

  • 安全にアンラップを行うための条件付き型定義
  • 再帰の深度を制御し、不必要な型展開を抑制する

/
type FlattenThenable = T extends PromiseLike ? FlattenThenable : T;

export function resolveDeeply(input: AsyncSource): Promise> {
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 implements PromiseLike {
constructor(private readonly executor: (resolve: (value: T) => void) => void) {}

then(
onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null
): 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` に縛られていれば、コンパイルエラーになるか、あるいは強制的にネイティブ `Promise` への変換(=不要なオブジェクト生成とガベージコレクション負荷)が強要される。

ここで `PromiseLike` を用いることで、「V8のメモリヒープを汚染しないカスタム非同期フロー」と「TypeScriptの厳格な型安全性」を完全に両立させることが可能になるのだ。

—

結び:型はランタイムの枷ではなく、芸術のためのキャンバスである

多くのプログラマーは、TypeScriptの型システムを「バグを防ぐためのガードレール」としか捉えていない。しかし、アーキテクトの視点において、型とは「ランタイムの挙動を正確に記述し、極限までパフォーマンスを引き出すためのメタ言語」である。

`PromiseLike` を使いこなすことは、単にコードの互換性を高めることではない。それは、あなたが書いたコードがV8エンジン上でどのように実行され、メモリがどうアロケートされ、イベントループがどう回るのかを、型レベルから完全に支配していることの証明なのだ。

フレームワークが用意した既製の型に安住せず、言語の仕様の深淵を覗き込め。そこに真のエンジニアリングの領域が広がっている。

タイトルとURLをコピーしました