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

コードレビューをしていて、最もよく見かけるアンチパターンの一つがこれだ。

// ❌ よくあるアンチパターン:Promiseに縛られた硬直した設計
async function executeTask(task: () => Promise): Promise {
const result = await task();
return result.toUpperCase();
}

一見すると何の問題もないコードに見えるかもしれない。しかし、この関数に 「同期的に即座に文字列を返す関数」 を渡そうとした瞬間、TypeScriptのコンパイラは冷酷にエラーを吐き捨てる。あるいは、無駄に `Promise.resolve()` でラップする冗長なコードを書かざるを得なくなる。

フロントエンドのコンポーネント設計や、プラグイン機構、ミドルウェアパイプラインを構築する際、私たちは「非同期関数(`async`)」と「同期関数(通常の関数)」の両方を、呼び出し側を意識させることなくシームレスに受け入れたい場面に直面する。

今回は、TypeScriptの型システムを深く理解し、`PromiseLike` を用いて同期・非同期の境界を美しく融解させる実務的アプローチを伝授しよう。

—

なぜ `Promise

TypeScriptの標準ライブラリには、`Promise` の他に `PromiseLike` というインターフェースが存在する。その定義を覗いたことはあるだろうか?

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

`PromiseLike`(別名:Thenable)の本質は、「`then` メソッドを持っているか否か」である。ネイティブの `Promise` だけでなく、RxJSのObservableの一部や、Bluebirdなどのサードパーティ製Promise、さらには独自の遅延評価オブジェクトまで、「非同期的に値が確定する可能性のあるすべてのオブジェクト」を包括できる。

しかし、今回のテーマはさらに一歩進める。「同期的な値(`T`)」と「非同期的な値(`PromiseLike`)」の両方を、単一のインターフェースで受け入れるための型定義だ。

—

実装パターン:`MaybePromise` と `Awaited` の極意

TypeScript 4.5以降、グローバルに組込まれている `Awaited` 型と、自作の `MaybePromise` を組み合わせることで、型安全性を一微も損なうことなく、極めて柔軟な関数シグネチャを構築できる。

以下のプロダクションコードを見てほしい。これは、フロントエンドのフォームバリデーションや、APIリクエスト前の前処理パイプラインを想定した実用的なユーティリティだ。

/

  • 同期的な値、または Promise や Thenable を表すユーティリティ型

/
type MaybePromise = T | PromiseLike;

/

  • 任意のタスク(同期・非同期)を受け取り、安全に正規化して実行するハンドラー

/
async function resolveTask(
input: TInput,
handler: (val: TInput) => MaybePromise
): Promise {
// ポイント: 戻り値が同期的であっても `await` は非同期的な値(PromiseLike)を透過的に解決する。
// また、生の値(TOutput)であればそのまま安全に通過させる。
return await handler(input);
}

// ==========================================
// 活用例:現場で使える堅牢なパイプライン
// ==========================================

// 1. 同期的なハンドラー
const syncTransform = (val: string): string => {
console.log(‘[Sync] Processing…’);
return val.trim().toLowerCase();
};

// 2. 非同期なハンドラー(APIコールを模倣)
const asyncTransform = async (val: string): Promise => {
console.log(‘[Async] Fetching from server…’);
await new Promise((r) => setTimeout(r, 100));
return val.toUpperCase();
};

async function main() {
// 同期関数を渡しても型エラーにならず、シームレスに動作する
const res1 = await resolveTask(‘ Hello TypeScript ‘, syncTransform);
console.log(res1); // “hello typescript”

// 非同期関数を渡しても、もちろん完璧に動作する
const res2 = await resolveTask(‘ Hello TypeScript ‘, asyncTransform);
console.log(res2); // “HELLO TYPESCRIPT”
}

main();

この設計が優れている理由

1. 呼び出し側の認知負荷ゼロ
コンポーネントのPropsやフックのコールバック渡す際、ユーザーは「この関数はasyncにすべきか?」と悩む必要がない。同期処理ならそのまま書き、I/Oが発生した瞬間だけ `async/await` に切り替えれば、受け入れる側は一切変更不要だ。
2. 無駄なオーバーヘッドの回避
すべてを `Promise` で受け取ろうとすると、同期関数であっても内部でマイクロタスクキューにプッシュされる(`Promise.resolve().then(…)` が走る)などのパフォーマンス上の微小なロスが生じる。`MaybePromise` と `await` のコンパイラ最適化を組み合わせることで、純粋な同期処理のパフォーマンスを維持できる。

—

パフォーマンスとコンパイル時の型評価における注意点

ここで、チーフアーキテクトとして一つ警告しておかなければならない。`Promise` や `PromiseLike` を扱う際、過剰なジェネリクスの連鎖はTypeScriptの型推論エンジン(tsserver)のCPU負荷を跳ね上げる。

特に、以下のような深すぎる型ガードや条件分岐を多用すると、エディタの補完が重くなる(いわゆる「Red Squiggly of Death」や、型チェックのタイムアウト)原因になる。

// ❌ 避けるべき過剰に複雑な型定義(型パースが重すぎる)
type DeepMaybePromise = T extends object
? { [K in keyof T]: DeepMaybePromise } | PromiseLike<{ [K in keyof T]: DeepMaybePromise }>
: MaybePromise;

実務でフロントエンドのコードベースをスケールさせたいならば、型定義は「必要十分なシンプルさ」を保つべきだ。関数引数における `MaybePromise` の適用は、ネストしたオブジェクト全体ではなく、「コールバックの戻り値」や「アクションの返却値」というピンポイントの境界に絞るのが最もROI(費用対効果)が高い。

—

まとめ:型定義は「制約」ではなく「表現力の拡張」である

初学者にとって、TypeScriptの型は「にいちゃん、変な値を入れるなよ」と怒ってくる警察官のように感じられるかもしれない。しかし、シニアエンジニアにとっての型とは、「コードの意図を正確に表現し、利用者の自由度を最大限に高めるためのセーフティネット」である。

`PromiseLike` を活用し、同期と非同期の垣根を取り払った関数設計を手に入れたあなたなら、もう無駄な `async` や `Promise.resolve()` でコードを汚すことはないはずだ。

明日のコードレビューで、誰かが `Promise` でガチガチに縛られた関数を書いていたら、こうアドバイスしてあげてほしい。

> 「ここ、`MaybePromise` にして同期関数も受け取れるようにしようか。その方が呼び出し側が圧倒的にエレガントになるよ」と。

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