コードレビューをしていて、最もよく見かけるアンチパターンの一つがこれだ。
// ❌ よくあるアンチパターン:Promise
async function executeTask(task: () => Promise
const result = await task();
return result.toUpperCase();
}
一見すると何の問題もないコードに見えるかもしれない。しかし、この関数に 「同期的に即座に文字列を返す関数」 を渡そうとした瞬間、TypeScriptのコンパイラは冷酷にエラーを吐き捨てる。あるいは、無駄に `Promise.resolve()` でラップする冗長なコードを書かざるを得なくなる。
フロントエンドのコンポーネント設計や、プラグイン機構、ミドルウェアパイプラインを構築する際、私たちは「非同期関数(`async`)」と「同期関数(通常の関数)」の両方を、呼び出し側を意識させることなくシームレスに受け入れたい場面に直面する。
今回は、TypeScriptの型システムを深く理解し、`PromiseLike` を用いて同期・非同期の境界を美しく融解させる実務的アプローチを伝授しよう。
—
なぜ `Promise
TypeScriptの標準ライブラリには、`Promise
interface PromiseLike
then
onfulfilled?: ((value: T) => TResult1 | PromiseLike
onrejected?: ((reason: any) => TResult2 | PromiseLike
): PromiseLike
}
`PromiseLike`(別名:Thenable)の本質は、「`then` メソッドを持っているか否か」である。ネイティブの `Promise` だけでなく、RxJSのObservableの一部や、Bluebirdなどのサードパーティ製Promise、さらには独自の遅延評価オブジェクトまで、「非同期的に値が確定する可能性のあるすべてのオブジェクト」を包括できる。
しかし、今回のテーマはさらに一歩進める。「同期的な値(`T`)」と「非同期的な値(`PromiseLike
—
実装パターン:`MaybePromise` と `Awaited` の極意
TypeScript 4.5以降、グローバルに組込まれている `Awaited
以下のプロダクションコードを見てほしい。これは、フロントエンドのフォームバリデーションや、APIリクエスト前の前処理パイプラインを想定した実用的なユーティリティだ。
/
- 同期的な値、または Promise や Thenable を表すユーティリティ型
/
type MaybePromise
/
- 任意のタスク(同期・非同期)を受け取り、安全に正規化して実行するハンドラー
/
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` や `PromiseLike` を扱う際、過剰なジェネリクスの連鎖はTypeScriptの型推論エンジン(tsserver)のCPU負荷を跳ね上げる。
特に、以下のような深すぎる型ガードや条件分岐を多用すると、エディタの補完が重くなる(いわゆる「Red Squiggly of Death」や、型チェックのタイムアウト)原因になる。
// ❌ 避けるべき過剰に複雑な型定義(型パースが重すぎる)
type DeepMaybePromise
? { [K in keyof T]: DeepMaybePromise
: MaybePromise
実務でフロントエンドのコードベースをスケールさせたいならば、型定義は「必要十分なシンプルさ」を保つべきだ。関数引数における `MaybePromise
—
まとめ:型定義は「制約」ではなく「表現力の拡張」である
初学者にとって、TypeScriptの型は「にいちゃん、変な値を入れるなよ」と怒ってくる警察官のように感じられるかもしれない。しかし、シニアエンジニアにとっての型とは、「コードの意図を正確に表現し、利用者の自由度を最大限に高めるためのセーフティネット」である。
`PromiseLike` を活用し、同期と非同期の垣根を取り払った関数設計を手に入れたあなたなら、もう無駄な `async` や `Promise.resolve()` でコードを汚すことはないはずだ。
明日のコードレビューで、誰かが `Promise
> 「ここ、`MaybePromise