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

はじめに:なぜ `Promise` ではなく `PromiseLike` なのか

モダンな大規模TypeScriptアーキテクチャを設計する際、関数の引数や高階関数のインターフェースに `Promise` を直接指定することは、厳密には過度な制約(Over-specification)となる場合が多い。

JavaScriptの歴史において、非同期処理の抽象化は単一の `Promise` クラスによって標準化されたわけではない。Promises/A+ 仕様に基づく「Thenable」オブジェクトの生態系が存在し、現在のECMAScript仕様(ES2015以降)においても、ランタイムの `await` や `Promise.resolve()` は標準の `Promise` インスタンスだけでなく、`then` メソッドを持つすべてのオブジェクト(Thenable)を同等にアンラップ処理する。

TypeScriptにおける `PromiseLike` は、この仕様を型システム上に精確にマッピングしたインターフェースである。

// lib.es5.d.ts における定義
interface PromiseLike {
then(
onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null
): PromiseLike;
}

本稿では、単なる型定義のテクニックにとどまらず、TypeScriptコンパイラの型評価アルゴリズム、V8エンジン内部におけるMicrotask Queueの消費メカニズム、そしてプロトタイプ汚染に起因するセキュリティ上の脆弱性まで、インフラ・コアレイヤを支えるアーキテクトが把握すべき低レイヤの知見を明かす。

—

コンパイラの眼:型システムにおける `PromiseLike` の推論・評価メカニズム

1. 構造的型付け(Structural Typing)とDuck Typingの厳密性

TypeScriptは公称型(Nominal Typing)ではなく構造的型付けを採用している。`Promise` は `PromiseLike` のサブタイプである(`catch` や `finally` メソッドを追加で持つため)。

一方、RxJSの `Observable`(特定の相互運用レイヤを挿入した場合)や、独自のエラーハンドリングを持つ軽量な非同期ライブラリ(`bluebird` や `q`、あるいは自作のDeferredオブジェクト)は、必ずしも `Promise` の全プロトタイプを継承しているわけではない。`PromiseLike` を受け入れ条件とすることで、インターフェースの柔軟性は最大化される。

2. `Awaited` 型による再帰的アンラップの罠と解剖

TypeScript 4.5で導入された組み込みユーティリティ型 `Awaited` は、`PromiseLike` の評価においてコンパイラ内部で以下のような再帰的型評価を行う。

// コンパイラ内部のメンタルモデル(簡易表現)
type CustomAwaited =
T extends null | undefined
? T
: T extends object & { then(onfulfilled: infer F, …args: any[]): any }
? F extends (value: infer V, …args: any[]) => any
? CustomAwaited
: never
: T;

ここでのポイントは、`PromiseLike` のアンラップ評価が再帰的である点だ。
もし `T` が `PromiseLike>` であれば、`Awaited` の型評価結果は `string` に収束する。

しかし、関数の型定義において単に `T | PromiseLike` と記述した場合、コンパイラは `T` 自体が `PromiseLike` である可能性を自動的に排除できない。型パラメータの制約(Generic Constraints)を適切に組まなければ、型推論の推移律が崩壊し、高階関数内で `Unwrap` 漏れが発生する。

—

ランタイムの深淵:V8エンジンとMicrotask Queueの最適化

コンパイラ上の型定義を最適化するだけでなく、それがV8ランタイム上でどのように展開されるかを理解しなければ、真のパフォーマンスチューニングは不可能である。

1. V8における Thenable Unwrapping のコスト

V8エンジンが `await` や `Promise.resolve(v)` に対面した際、引数 `v` が純粋な `Promise` インスタンスであるか、単なる `Thenable`(`PromiseLike`)であるかによって実行パスが劇的に分岐する。

[ await v の判定フロー ]
├── v が組み込み Native Promise インスタンスの場合:
│ └── Fast Path: 内部スロット [[PromiseState]] を直接参照。
│ 不要な Task 割り振りをスキップし、最小限の Microtask で解決。
└── v が PromiseLike(Thenable オブジェクト)の場合:
└── Slow Path: 規格に則り `v.then` プロパティアクセスを実行。
新規に `PromiseResolveThenableJob` を生成し、Microtask Queue に積む。
→ 追加で最低2回のTick(コンテキストスイッチ)が発生。

`PromiseLike` を型として広く受け入れる設計は柔軟性を提供するが、「何でもかんでも `await` や `Promise.resolve` に突っ込む」実装は、不要なマイクロタスクを大量発生させ、V8のJITコンパイラ(Turbofan)によるインライン化を阻害する。

2. アロケーション・オーバーヘッド:同期 / 非同期透過型実行の必要性

不要な `async` 関数化は、呼び出しごとに以下のオブジェクトをV8ヒープ上に強制アロケーションする。
1. `JSPromise` オブジェクト
2. `PromiseReaction` オブジェクト(Success/Rejectionハンドラ用)
3. 逃避解析(Escape Analysis)に失敗した閉包(Closure)のコンテキスト

処理が同期的に完了可能(例: キャッシュ命中時)であるにもかかわらず、一律で `Promise` を返却・待機する設計は、GC(Garbage Collection)プレッシャーを増大させる。

—

極限の型設計パターン:同期・非同期透過型API(Zero-Allocation Pipeline)

以下に示すのは、値が「同期値」「`PromiseLike`」「非同期処理関数」のいずれであっても透過的に受け入れ、同期的完了が可能なパスでは即時リターンしてメモリ割り当てをゼロに抑え、非同期値のときのみ非同期評価にフォールバックする極限まで最適化されたコアユーティリティの実装例である。

/

  • 同期値、PromiseLike、またはそれらを返す関数の型定義

/
export type SyncOrAsyncProvider =
| T
| PromiseLike
| ((…args: any[]) => T | PromiseLike);

/

  • Thenable(PromiseLike)か否かを判定する型ガード(Type Guard)
  • V8のプロパティアクセスコストを考慮し、最小限の評価に抑える

/
export function isPromiseLike(val: unknown): val is PromiseLike {
return (
val !== null &&
(typeof val === ‘object’ || typeof val === ‘function’) &&
typeof (val as Record).then === ‘function’
);
}

/

  • 同期・非同期透過型実行エンジン
  • コンパイル時型推論:
  • – T が同期値なら SynchronousReturnValue として評価
  • – T が PromiseLike なら Promise> へ推論を展開

/
export function resolveValue(
provider: SyncOrAsyncProvider
): T extends PromiseLike ? Promise> : T | Promise> {
// 1. 関数プロバイダの評価
const value = typeof provider === ‘function’
? (provider as () => T | PromiseLike)()
: provider;

// 2. Fast Path: PromiseLike でなければ、即座に同期値を返却(Microtaskを発生させない)
if (!isPromiseLike>(value)) {
return value as any;
}

// 3. Slow Path: PromiseLike の場合は標準 Promise に昇格させてチェーン
return Promise.resolve(value).then((unwrapped) => unwrapped) as any;
}

// —————————————————————————
// 動作検証と型評価のメンタルモデル
// —————————————————————————

// 【ケース 1】完全同期実行パス(ヒープアロケーション最小化)
const syncResult = resolveValue(() => 42);
// 型: number
// 実行時: 即座に 42 を返し、Microtask Queueを経由しない。

// 【ケース 2】Custom Thenable の透過的解決(Promises/A+ 互換)
const customThenable: PromiseLike = {
then(onfulfilled) {
if (onfulfilled) onfulfilled(“OK”);
return this;
}
};

const asyncResult = resolveValue(customThenable);
// 型: Promise
// 実行時: PromiseLike であることを検知し、安全に Promise へ収束。

この設計の強み

1. コンパイル時推論: 戻り値の型が `Awaited` によって再帰的に展開され、呼び出し側に不要な `Promise` の二重包みが発生しない。
2. ランタイム効率: 同期値が渡された場合、`await` も `Promise.resolve()` も介さずにコールスタック上で処理が完了する。V8のMicrotask Queueへのコンテキストスイッチを完全に削減できる。

—

防御的プログラミングとセキュリティ:Thenable 汚染(Thenable Unwrapping Hazards)

セキュリティ研究者やアーキテクトが絶対に看過してはならないのが、`PromiseLike` / `Thenable` の自動アンラップ機構を悪用した攻撃(Thenable Processing Vulnerability)である。

1. Duck Typingに潜むインジェクション攻撃

Node.js環境やセキュリティ境界を越えるデータ(JSONパース結果、外部RPCのレスポンスなど)をそのまま `PromiseLike` として処理すると、予期せぬ実行経路へ誘導される危険性がある。

例えば、攻撃者が構築した悪意あるオブジェクトが `then` メソッドを持っている場合、ランタイムがそれを `await` または `Promise.resolve()` した瞬間に、攻撃者のコードが特権コンテキスト(Microtask)上で自動実行される。

// 外部からの未検証データ(例: 悪意あるペロードの受信)
const untrustedData: unknown = JSON.parse(`{
“user”: “admin”,
“then”: “javascript:alert(1)”
}`);

// もし prototype 汚染等で then が関数として評価された場合:
const maliciousObject = {
data: “payload”,
then: (onFulfilled: Function, onRejected: Function) => {
// 開発者が意図しない特権処理や無限ループ、メモリリークの誘発
console.error(“CRITICAL: Executed inside internal microtask!”);
onRejected(new Error(“Hijacked!”));
}
};

// 安全でない型キャストとアンラップの例
async function unsafeProcess(input: unknown) {
// input が Thenable であるかのように判定され、任意コード実行のトリガーになり得る
const result = await input;
return result;
}

2. セキュリティ境界における Sanitization 境界の構築

`PromiseLike` を安全に受け入れるための設計原則として、ドメイン領域(信頼領域)の外側から入ってくるオブジェクトに対しては、絶対に `PromiseLike` を自動推論させてはならない。

受入境界においては、明示的に `Object.freeze` を施すか、`then` プロパティの所有権(`Object.prototype.hasOwnProperty`)を厳格に検証する「サニタイズ層」を挿入せよ。

/

  • 外部(Untrusted Zone)から来たデータが安全な PromiseLike かどうかを判定する
  • プロトタイプ汚染対策済みの型ガード

/
export function isSafePromiseLike(val: unknown): val is PromiseLike {
if (val === null || (typeof val !== ‘object’ && typeof val !== ‘function’)) {
return false;
}

// 自身のプロパティとして then を保持しているか(プロトタイプ汚染の回避)
const hasOwnThen = Object.prototype.hasOwnProperty.call(val, ‘then’);

if (!hasOwnThen) {
return false;
}

const descriptor = Object.getOwnPropertyDescriptor(val, ‘then’);

// getter アクセサによるサイドエフェクト(ゲッター爆弾)を警戒
if (descriptor && typeof descriptor.value === ‘function’) {
return true;
}

return false;
}

—

まとめ:アーキテクトが刻むべき一線

`PromiseLike` の活用は、単なる「型定義を緩めて便利にする」ための道具ではない。

1. 型システムの厳密化: `Awaited` と組み合わされた再帰的型推論を正確に制御し、過度な型制約を排除する。
2. ランタイムの最適化: 同期・非同期の境界線を可視化し、V8エンジンの Fast Path(同期完了パス)を活かすことで、不要な Microtask 割り当てとメモリ消費を回避する。
3. セキュリティの掌握: `Thenable` オブジェクトの暗黙のアンラップ挙動を熟知し、境界領域におけるインジェクション攻撃を遮断する。

静的な型定義の向こう側にあるランタイムの振る舞いとメカニズムを完璧に掌握してこそ、堅牢かつ極限のパフォーマンスを叩き出すシステムアーキテクチャが完成するのである。

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