【実務・中級編】引数に渡す関数の「引数の型」を推論させるためのジェネリクス・カリー化テクニック – TypeScript コア・型システムの基礎解析バイブル

TypeScriptの型システムを日常的に使っていると、一度は次のような絶望感を味わったことがあるはずだ。

「なぜ、コールバック関数の引数型が `any` に落ちるんだ?」
「親関数のジェネリクスを固定した途端、子関数の引数の推論が死んでしまった……」

フロントエンドのフォームライブラリ、状態管理、あるいは独自の非同期パイプラインを設計している時、高階関数(関数を返す関数、または関数を引数にとる関数)の型推論が崩壊する現象は、実務における開発効率とコードの信頼性を著しく蝕む。

今回は、TypeScriptのコンパイラが型をどのように評価し、なぜ推論が失敗するのかというメカニズムの深層に踏み込みつつ、「親のジェネリクスから子の引数型を完璧に自動推論させる」ためのカリー化とジェネリクスの極限テクニックを授けよう。

—

なぜ「普通の書き方」では型推論が崩壊するのか?

まずは、よくあるアンチパターンから見ていこう。何気なく書いた次のような高階関数は、一見すると美しく動くように見える。

// 【アンチパターン】一見正しそうに見えるが、実務で破綻するコード
type Fetcher = (id: string) => Promise;

// 親関数:データ取得のラッパーを作る関数
function createQuery(fetcher: Fetcher) {
return (callback: (data: TData) => void) => {
// なんらかの処理…
fetcher(“123”).then(callback);
};
}

// 使い方
const useUserQuery = createQuery(async (id) => {
// 問題:ここで id の型が string になるべきだが、
// 親の TData が決まっていないため、fetcherの引数型が定まらない!
return { id: “1”, name: “Taro” };
});

このコードの何が問題か?
TypeScriptのコンパイラは、関数に渡された引数から型を推論(Inference)する際、左から右へ、あるいは外側から内側へという評価の順序を持つ。

上記の `createQuery` では、引数である `fetcher` の型 `Fetcher` の `TData` を確定させるために、ユーザーは `fetcher` 自体の実装(`async (id) => …`)を渡さなければならない。しかし、その `fetcher` の引数 `id` の型が `string` であることをコンパイラに教える手段が失われているため、型推論のループに陥り、最終的に `any` や `unknown` にフォールバックしてしまうのだ。

プロフェッショナルなコードベースにおいて、コールバックの引数に型を手動注釈(`id: string` など)させなければならない設計は、それだけで「設計の敗北」を意味する。

—

解決策:分断された推論を繋ぐ「カリー化(Currying)」と文脈の伝播

この問題を根本から解決するのが、「引数を分割し、複数の関数呼び出し(カリー化)にステップを分けること」である。

TypeScriptのコンパイラは、関数がネストしている(関数を返す関数である)場合、前のステップで確定した型情報を次のステップのジェネリクスへと正確に伝播させることができる。

以下のプロダクションコードを見てほしい。これが、複雑なAPIクライアントやフォームバリデーション基盤を構築する際の黄金パターンだ。

/

  • 【プロダクション・パターン】
  • 型の方向性と推論のコンテキストを完全に制御する高階関数ファクトリ

/

// 1. まず「入力データの型(I)」と「出力データの型(O)」を受け取る第一段
export function createPipeline() {
return {
// 2. 次に、その入力を受け取って処理する非同期アクションを定義させる
// ここで TInput がコールバックの引数の型として完全にバインドされる
handler(fn: (input: TInput) => Promise) {
return {
// 3. 最後に、出力結果を受け取る後続処理を接続する
execute(onSuccess: (result: TOutput) => void) {
return async (rawInput: TInput): Promise => {
const result = await fn(rawInput);
onSuccess(result);
};
},
};
},
};
}

// ==========================================
// 実践的な使用例
// ==========================================

interface UserPayload {
userId: string;
}

interface UserProfile {
id: string;
name: string;
email: string;
}

// ステップを踏んだ美しい型推論の連鎖
const processUserAction = createPipeline() // 1. 入力型を明示(または推論させる)
.handler(async (input) => {
// ★ここで input の型は完璧に `UserPayload` として推論される!
// 手動の型注釈(input: UserPayload)は一切不要。
const response = await fetch(`/api/user/${input.userId}`);
return (await response.json()) as UserProfile;
})
.execute((profile) => {
// ★さらに前のステップの TOutput(UserProfile)が、
// ここで自動的に `profile` の型として伝播する!
console.log(`Hello, ${profile.name}!`);
});

// 実行時
processUserAction({ userId: “usr_999” });

このコードが優れている理由

1. 完全な型安全とゼロ・アノテーション: 開発者はコールバック関数(`input` や `profile`)の引数に型を書く必要が一切ない。IDEの補完は完璧に働き、タイポやプロパティの存在しないアクセスはコンパイルエラーとして即座に弾かれる。
2. 関心の分離: 「何を入力し(Input)」「どう変換し(Output)」「どう副作用を処理するか(Success)」というパイプラインのフェーズが型レベルで厳密に強制される。
3. 推論の方向性の制御: TypeScriptのコンパイラが「どの順序で型を解決すべきか」を、オブジェクトのメソッドチェーン(あるいはカリー化された関数呼び出し)によって明確にガイドしている。

—

コンパイラをハックする:応用編(条件付き型と部分適用)

さらに実務を突き詰めると、「引数の型を推論させつつ、一部のジェネリクスだけを手動で固定したい」というジレンマに直面する。例えば、エラーハンドリングの型を共通化しつつ、データ構造だけを動的に変えたい場合などだ。

このようなケースでは、「カリー化された関数の第一引数に型推論をバイパスするプレースホルダーを置き、第二引数でコールバックの型をキャプチャする」という高等テクニックが求められる。

// 高度な依存性注入(DI)風の高階関数
function withErrorBoundary() {
return (
// コールバック関数の引数型 (TArgs) と 戻り値の型 (TData) を同時に推論させる
operation: (…args: TArgs) => Promise
) => {
return async (…args: TArgs): Promise => {
try {
return await operation(…args);
} catch (e) {
const err = e as TError;
console.error(“Operation failed:”, err);
return null;
}
};
};
}

// 使い方:
// エラー型をカスタムしつつ、実引数の型は関数から自動推論させる
const safeFetchUser = withErrorBoundary()(
async (userId: string, includeDetails: boolean) => {
// userId は string, includeDetails は boolean として厳密に推論される
if (!includeDetails) throw new TypeError(“Invalid flag”);
return { id: userId, role: “admin” };
}
);

なぜ `withErrorBoundary()(…)` という奇妙な二重括弧なのか?

これがTypeScriptにおけるカリー化によるジェネリクス制御の真髄である。
TypeScriptは、ひとつの関数呼び出し(`(…)`)の中で、「手動で渡す型パラメータ」と「引数から推論する型パラメータ」を混在させると、推論が不安定になるか、あるいは両方とも手動で書かざるを得なくなるという致命的な制限を持っている。

そのため、
1. 最初の関数呼び出しで「環境や共通設定(エラー型など)」を流し込み(`withErrorBoundary()`)
2. 次の関数呼び出しで「具体的なビジネスロジックの関数」を渡し、その引数型を完全に推論させる(`(…)`)

という2段階の構造を取ることで、コンパイラの推論エンジンの限界を鮮やかに回避しているのだ。

—

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

フロントエンドやNode.jsのコードベースが巨大化するにつれて、「型推論が効かないから仕方なく `any` や `as` でキャストする」という妥協が、技術的負債の温床となる。

今回解説したカリー化とジェネリクスの組み合わせによる型伝播のテクニックは、単なる「型パズル」ではない。「コンパイラに正しい思考の順序を強制し、開発者の認知負荷を極限まで下げるためのエンジニアリング手法」である。

コードレビューで「なぜここに型注釈が必要なんだ? 推論させられるはずだ」という指摘ができるか否か。それこそが、単なるTypeScriptユーザーと、言語を掌握したチーフアーキテクトを分かつ境界線なのである。

あなたの設計するライブラリやコンポーネントのAPIにおいて、今すぐこのパターンを取り入れ、開発チームを「型エラーの呪縛」から解放してほしい。

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