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

Promiseに縛られるな。`PromiseLike`で実現する同期・非同期の完全掌握とゼロコスト・アブストラクション

フロントエンドの設計において、コンポーネントのProps、ミドルウェア、あるいはキャッシュ層のインターフェースを定義する際、無意識に `(…) => Promise` と書いていないだろうか?

「非同期処理になるかもしれないから、とりあえず `Promise` で包んでおく」——この安易な妥協が、無駄なマイクロタスクの生成によるパフォーマンス劣化と、サードパーティライブラリ(RxJS, Bluebird, WASMバインディング等)との互換性欠如という形でプロダクトを蝕んでいく。

本稿では、TypeScriptコアの型評価メカニズムを紐解きながら、`PromiseLike` を活用して同期と非同期をシームレスかつ最速で捌く堅牢なアーキテクチャを伝授する。

—

1. なぜ `Promise` ではなく `PromiseLike` なのか?

まず、TypeScript型システムにおける `Promise` と `PromiseLike` の根本的な決定差を理解しなければならない。

// lib.es5.d.ts における定義の概念図

interface PromiseLike {
/

  • Attaches callbacks for the resolution and/or rejection of the PromiseLike.

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

interface Promise {
then(…): Promise;
catch(…): Promise;
finally(onfinally?: (() => void) | undefined | null): Promise;
readonly [Symbol.toStringTag]: string; // Nominal性の象徴
}

構造的型付け(Structural Typing)の真諦

`Promise` は、ES2015標準の組み込み `Promise` インスタンスであることを要求する名目的要素の強い型だ。一方、`PromiseLike` は 「`.then()` メソッドを持っているオブジェクト(Thenable)」 であればすべて許容する純粋な構造的インターフェースである。

Webアプリケーションの現場では、以下のオブジェクトが頻繁に登場する。

1. RxJSの `Observable` を `from()` 変換したもの、または Thenable 化されたカスタムストリーム
2. WASM(WebAssembly)モジュールが返す非同期プロキシ
3. マイクロフロントエンド環境で別領域(iframeやWeb Worker)からポストメッセージ経由で渡される Thenable オブジェクト

これらを `Promise` で制限すると、余計な `Promise.resolve()` による再ラップが必要になり、不要なオブジェクト生成コストが発生する。柔軟なAPIのインプットには `PromiseLike` を採用するのが、JavaScriptランタイムのセマンティクスに沿った正解である。

—

2. 型パズルを排除する: `Awaited` と `MaybePromise` の定義

関数の引数や戻り値において、「同期値」と「非同期値(Thenable)」の両方を透過的に受け入れるための基本型パターンを構築する。

/

  • 同期値 T または Thenable な T を許容するユニオン型

/
export type MaybePromise = T | PromiseLike;

/

  • 高階関数のハンドラ定義: 同期・非同期双方の関数を透過的に受け入れる

/
export type FlexibleHandler = (
…args: TArgs
) => MaybePromise;

ここで重要なのが、TypeScript 4.5から導入された標準ユーティリティ型 `Awaited` だ。
`Awaited` は、再帰的に `PromiseLike` のネストを解きほぐし、最終的に取り出される純粋な値の型を導出する。

// TSコンパイラ内部での評価イメージ(概念コード)
type CustomAwaited = T extends null | undefined
? T
: T extends object && typeof (T as any).then === ‘function’ // PromiseLikeの判定
? T extends { then(onfulfilled: (value: infer V) => any): any }
? CustomAwaited // 再帰的なアンラップ
: never
: T;

この型システム上の挙動があるからこそ、我々は「同期か非同期か」に関わらず、最終的な戻り値の型を型安全に推論できるようになる。

—

3. ランタイムの真実: `await Promise.resolve()` が生む「マイクロタスク・タスクオーバーヘッド」

レビューで最もよく見かけるアンチパターンがこれだ。

// ❌ 悪いコード: 同期・非同期を一元化するために何でもかんでも Promise.resolve で包む
async function executeBad(fn: () => MaybePromise): Promise {
return await Promise.resolve(fn()); // 同期処理であっても強制的にマイクロタスクキューへ投入される
}

なぜこれが非効率なのか?(V8エンジンの視点)

JavaScriptのイベントループにおいて、`Promise.resolve()` や `async/await` は処理をマイクロタスクキュー(Microtask Queue) にスケジューリングする。

1. 関数 `fn()` が同期的に `42` を返したとする。
2. `Promise.resolve(42)` により、即座に解決されたPromiseインスタンスが生成される。
3. `await` により、後続のコードはコールスタックから外れ、マイクロタスクキューに登録される。
4. 現在のコールスタックが完全に空になるまで、後続処理は実行されない。

1秒間に数万回呼ばれる可能性のあるユーティリティや、高頻度で更新されるUIコンポーネント(React/VueのState変更に連動する処理など)でこれをやると、不要なコンテキストスイッチと遅延(レイテンシ) が積み重なり、フレームロストを引き起こす。

解答: 「Sync-Fast Path(同期高速パス)」パターン

同期実行が可能な場合はコールスタック上で即時実行し、Thenableなオブジェクトが返ってきた場合のみ非同期パスへ分岐させる。これがパフォーマンスを極限まで高める設計哲学である。

—

4. プロダクションコード例: 堅牢かつ最速のキャッシュ機能付きパイプライン処理

以下は、フロントエンドのデータ取得層やミドルウェアエンジンとしてそのまま使えるプロダクションレベルの実装例だ。

1. 厳格な `isPromiseLike` ユーザー定義型ガード

/

  • 対象のオブジェクトが PromiseLike (Thenable) であるかを評価する厳格な型ガード。
  • 単なる null チェックだけでなく、Function / Object のプロパティとしての then を検証する。

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

2. 同期・非同期透過型キャッシュレイヤーの実装

import { MaybePromise, isPromiseLike } from ‘./type-guards’;

export interface DynamicCacheAdapter {
// キャッシュ取得は同期(メモリ)かもしれないし非同期(IndexedDB/LocalStorage等)かもしれない
get(key: K): MaybePromise;
set(key: K, value: V): MaybePromise;
}

export class OptimizedDataFetcher {
constructor(
private cache: DynamicCacheAdapter,
private remoteFetcher: (key: K) => Promise
) {}

/

  • Sync-Fast Path パターンを用いたデータ取得メソッド。
  • キャッシュヒット時かつ同期値であれば、一切の非同期オーバーヘッド(マイクロタスク化)なしで値を返す。

/
public fetch(key: K, onComplete: (data: V) => void): MaybePromise {
const cachedResult = this.cache.get(key);

// — Fast Path: キャッシュ判定が同期的に完了した場合 —
if (!isPromiseLike(cachedResult)) {
if (cachedResult !== undefined) {
onComplete(cachedResult);
return cachedResult; // 即座に値を返し、関数を終了(コールスタック上で完結)
}

// キャッシュミス時のリモート取得(非同期)
return this.fetchAndCache(key, onComplete);
}

// — Slow Path: キャッシュ取得自体が PromiseLike(非同期)の場合 —
return cachedResult.then((asyncCachedValue) => {
if (asyncCachedValue !== undefined) {
onComplete(asyncCachedValue);
return asyncCachedValue;
}
return this.fetchAndCache(key, onComplete);
});
}

private fetchAndCache(key: K, onComplete: (data: V) => void): Promise {
return this.remoteFetcher(key).then((remoteData) => {
const setOperation = this.cache.set(key, remoteData);

if (isPromiseLike(setOperation)) {
// バックグラウンドで書き込み完了を待機(呼び出し元はデータ受信を優先できる)
setOperation.then(undefined, (err) =>
console.error(`Failed to update cache async for key: ${String(key)}`, err)
);
}

onComplete(remoteData);
return remoteData;
});
}
}

—

5. コードレビュー:アンチパターン vs 改善パターン

テクニカルリードとして、チームメンバーのPRをレビューするシーンを想定してほしい。

❌ 指摘対象のPRコード

// レビュアー: 「なぜ同期で済む可能性がある処理を、すべて async/await で強制非同期化しているのか?」
export const createTransformer = (
transform: (input: T) => R | Promise
) => {
return async (data: T[]): Promise => {
const results: R[] = [];
for (const item of data) {
// transform が同期関数であっても、毎回ループ内で await によるマイクロタスク遅延が発生する
const transformed = await transform(item);
results.push(transformed);
}
return results;
};
};

問題点まとめ

1. `transform` の型定義が `Promise` に限定されており、他ライブラリの `PromiseLike`(Thenable)を受け入れられない。
2. 配列の全要素が同期的に変換可能な場合でも、要素数の分だけイベントループのマイクロタスクキューを経由するため、処理速度が壊滅的に低下する。

—

✅ 改善後のコード

/

  • 同期・非同期を高度に最適化したトランスフォーマー。
  • 配列全体が同期的に処理可能な場合は、戻り値自体を即座に T[] (同期) として返す。

/
export const createOptimizedTransformer = (
transform: (input: T) => MaybePromise
) => {
return (data: T[]): MaybePromise => {
const results: R[] = [];

for (let i = 0; i < data.length; i++) { const transformed = transform(data[i]); if (isPromiseLike(transformed)) { // 途中で非同期処理(PromiseLike)が検出されたため、ここから非同期モードに切り替える(Slow Path) return processAsyncRest(data, i, results, transformed, transform); } // 同期的に処理を継続(Fast Path) results.push(transformed); } // 全要素が同期的に完了した場合、Promiseではなく生の配列を返す return results; }; }; /

  • 非同期フォールバック用内部ヘルパー

/
async function processAsyncRest(
data: T[],
currentIndex: number,
accumulatedResults: R[],
currentPromise: PromiseLike,
transform: (input: T) => MaybePromise
): Promise {
// 検出された PromiseLike を解決
accumulatedResults.push(await currentPromise);

// 残りの要素を再帰的に処理
for (let i = currentIndex + 1; i < data.length; i++) { const transformed = transform(data[i]); if (isPromiseLike(transformed)) { accumulatedResults.push(await transformed); } else { accumulatedResults.push(transformed); } } return accumulatedResults; }

この設計がもたらす圧倒的勝利

1. 柔軟性: `PromiseLike` により、あらゆる非同期ライブラリやカスタム Thenable と接続可能。
2. 極限のパフォーマンス: 入力データと変換ロジックが同期的に解決可能な場合、0個のPromiseオブジェクト生成・0回のマイクロタスク遅延 で処理が完了する(完全なゼロコスト抽象化)。
3. 完全な型安全性: 呼び出し側は `isPromiseLike(result)` を使うことで、同期的に値を取り出すか `await` するかを自発的に選択できる。

—

6. まとめ

設計者・アーキテクトとしてのTypeScriptの極意は、単に「型エラーを消すこと」ではない。「型定義によってJavaScriptランタイムの真のパフォーマンスを引き出し、利用者に安全なAPIを提供すること」 にある。

  • インターフェースの非同期許容には `Promise` ではなく `PromiseLike` (`MaybePromise`) を使え。
  • 内部実装では `isPromiseLike` ガードを置き、Sync-Fast Path(同期優先処理) を徹底せよ。
  • `async/await` は魔法の呪文ではない。不要なステップでの乱用はパフォーマンスの毒になることを胸に刻め。

この設計パターンをマスターし、一歩先の堅牢なフロントエンド・アーキテクチャを構築してほしい。

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