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

こんにちは。テクニカルリードの私だ。

コードレビューをしていると、未だに以下のようなコードに遭遇する。

// よく見る、だが実務では脆弱なコード
async function processUserData(fetcher: () => Promise): Promise {
const user = await fetcher();
// 何らかの処理…
return user;
}

「おや、一見して問題なさそうだが?」と思った君。TypeScriptの型システムとJavaScriptのランタイムの非同期境界を、まだ表面しか見えていない証拠だ。

この `Promise` という限定的な型定義は、実務の現場では「毒」になり得る。サードパーティ製のライブラリが返すカスタムのThenableオブジェクトや、RxJSのObservable、あるいはテスト時のモック(Zone.js環境や独自の遅延評価オブジェクトなど)、そして将来的な仕様変更による非同期インターフェースの差し替えに対して、このコードは完全に門戸を閉ざしている。

今回は、TypeScriptの型システムを極限までハックし、あらゆる「非同期の萌芽」を安全に飲み込むための `PromiseLike` を用いた堅牢な関数型設計について、コンパイラの挙動から実務コードまで徹底的に解説しよう。

—

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

TypeScriptにおける `Promise` は、ES2015の標準プロミス実装とそのインスタンス型を厳密に指す。しかし、JavaScriptエコシステムにおける「非同期」の本質は、クラスとしての `Promise` である必要はない。「`then` メソッドを持つオブジェクト(Thenable)」であれば、それはランタイム上では非同期処理として振る舞うことができる。

これを型レベルで表現するのが、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`: `async/await` のネイティブサポートや、`.catch()`, `.finally()` などの豊富なメソッドチェーンを持つコンクリートな型。
  • `PromiseLike`: 「`then` さえ生えていれば、それは非同期タスクである」とみなす、構造的型付け(Structural Subtyping)の極み。

関数の引数や戻り値の受け渡しにおいて、「入力はより広く(Contravariant)、出力はより狭く(Covariant)」受け入れるべきという原則に基づけば、非同期関数が受け取るデータソースは `Promise` に縛られるべきではなく、`PromiseLike`(あるいはそのunion)であるべきなのだ。

—

2. 実務で直面するアンチパターンと設計の課題

例えば、フロントエンドのデータフェッチ層や、BFF(Backend for Frontend)のクライアントSDKを設計している場面を想像してほしい。

// 悪い例:具象クラスに依存したタイトカップリングな設計
class ApiClient {
async get(url: string): Promise {
const res = await fetch(url);
return res.json();
}
}

// テスト時にモックや独自キャッシュ機構(Promiseではないがthenableなカスタムオブジェクト)を
// 差し込もうとした瞬間に型エラーの嵐に見舞われる。

ここで `Promise` を強制していると、カスタムの遅延評価キャッシュや、RxJSの `toPromise()` のようなレガシー実装、あるいはサードパーティの特殊な非同期ラッパーを渡した瞬間にTypeScriptコンパイラに弾かれる。無駄な `Promise.resolve()` のラップを強制されることになり、ランタイムのマイクロタスクキューを汚染する原因にもなる。

—

3. プロダクションコード:`PromiseLike` を極めた汎用非同期パイプライン

では、実務の現場でそのまま使える、極限まで柔軟かつ厳格な型安全性を誇る非同期ユーティリティ関数の実装を見ていこう。

以下のコードは、任意の非同期タスク(`Promise` でも、独自の `Thenable` でも、同期的な値ですら)を受け取り、安全にハンドリングして返すパイプラインの設計例だ。

/

  • 任意の非同期値、あるいは同期的な値を安全にラップし、
  • 最終的に標準の Promise として解決するロバストなパイプライン関数。

/

// T に加え、Thenable である PromiseLike も許容するユーティリティ型
type AsyncInput = T | PromiseLike;

/

  • 実行時に関数、または非同期値を受け取り、安全に解決する高階関数

/
export async function executeAsyncOperation(
inputSource: AsyncInput | (() => AsyncInput),
transform: (val: Awaited) => AsyncInput
): Promise> {
try {
// 1. 入力が関数であれば実行し、でなければそのまま評価
const rawInput = typeof inputSource === ‘function’
? (inputSource as () => AsyncInput)()
: inputSource;

// 2. PromiseLike かどうかをコンパイル時・実行時で安全に解決
// Awaited 型により、ネストされた Promise も一網打尽にアンラップされる (TS 4.5+)
const resolvedInput = await rawInput;

// 3. トランスフォームの適用
const rawOutput = transform(resolvedInput as Awaited);

// 4. 出力側も PromiseLike の可能性を考慮して await
const finalOutput = await rawOutput;

return finalOutput as Awaited;
} catch (error: unknown) {
// 厳格なエラーハンドリング(unknown 型ガードの活用)
if (error instanceof Error) {
throw new Error(`[AsyncPipelineError]: Failed to execute operation -> ${error.message}`);
}
throw new Error(‘[AsyncPipelineError]: An unknown non-Error rejection occurred.’);
}
}

このコードのアーキテクチャ的優位性

1. `Awaited` との完璧なシナジー
TypeScript 4.5以降で導入された `Awaited` 型と組み合わせることで、`Promise>` や深層にネストされた `PromiseLike` を型レベルで完全に平坦化(Unwrap)している。これにより、呼び出し側は型の深さを意識する必要がない。
2. 遅延評価(Lazy Evaluation)への対応
引数に `AsyncInput | (() => AsyncInput)` を許容しているため、関数が呼び出される瞬間まで評価を遅らせたいケース(リトライロジックや、必要時のみ発火させたいAPIコール)にもシームレスに対応できる。
3. ランタイムと型定義の完全な同期
`await` キーワードは、ランタイムにおいてネイティブの `Promise` だけでなく、仕様を満たすすべての `PromiseLike`(`thenable`)オブジェクトをネイティブプロミスに昇格させる特性を持っている。この関数はそのJavaScriptの仕様を型レベルで正確にトレースしている。

—

4. 実戦投入:カスタムThenableとの統合例

この設計がどれほど強力か、実際のユースケースを見てみよう。以下は、標準の `Promise` ではなく、独自のキャッシュ機構を持つカスタム `Thenable` オブジェクトをこの関数に流し込む例だ。

// 独自のキャッシュ付き Thenable オブジェクト(Promiseクラスを継承していない)
class SimpleCacheThenable implements PromiseLike {
constructor(private readonly data: T) {}

// then メソッドを持つため、TypeScriptの型システム上もランタイム上も PromiseLike として扱える
then(
onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike) | null
): PromiseLike {
try {
if (onfulfilled) {
// 同期的に解決をシミュレート
const result = onfulfilled(this.data);
// 簡易的な Thenable の返却
return new SimpleCacheThenable(result as any) as any;
}
return this as any;
} catch (error) {
if (onrejected) {
const errorResult = onrejected(error);
return new SimpleCacheThenable(errorResult as any) as any;
}
throw error;
}
}
}

// — 実行と検証 —
async function run() {
// 1. 通常の値を渡す
const res1 = await executeAsyncOperation(42, (val) => val 2);
console.log(res1); // 84 (型は Promise)

// 2. ネイティブの Promise を渡す
const res2 = await executeAsyncOperation(
Promise.resolve(“hello”),
(val) => val.toUpperCase()
);
console.log(res2); // “HELLO” (型は Promise)

// 3. 【真骨頂】カスタムの Thenable(Promiseではない)を渡す!
const cacheObj = new SimpleCacheThenable(100);
const res3 = await executeAsyncOperation(
cacheObj,
(val) => Promise.resolve(val + 50) // 内部で Promise を返す変換も完璧に型推論される
);
console.log(res3); // 150 (型は Promise)
}

run();

コンパイルエラーは一切起きず、IDEのインテリセンスはすべてのステップで正確な型(`number` や `string`)を完全に追跡し続ける。これが、型定義を徹底的に抽象化したアーキテクチャの美しさだ。

—

テクニカルリードからの総括

初心者は `Promise` を好み、成熟したアーキテクトは `PromiseLike`(あるいは抽象化された `AsyncInput`)を制す。

フロントエンドの複雑性が増し、ReactのSuspenseやServer Components、サードパーティのデータフェッチライブラリが複雑に絡み合う現代のWeb開発において、関数の引数や戻り値を具象的な `Promise` に固定化することは、将来の拡張性を自らブチ折る行為に等しい。

「非同期処理を扱う関数を書くときは、常にその入力が『Thenable』であり得ることを想定せよ」

この知見をコードベースに浸透させれば、君のチームのコードは、いかなる変更や外部ライブラリの差し替えに対しても揺るぎない、極限まで堅牢なシステムへと昇華されるはずだ。さっそく今日のプルリクエストから、型定義を見直してみたまえ。

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