【実務・中級編】引数に渡す「関数」の戻り値型を、別の引数の型として利用する「依存型」の応用 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptを極める:関数パイプラインにおける「依存型」の設計と実装

コードレビューをしていて、よくこんなコードに出くわすことはないだろうか。

// よくある「型を諦めた」パイプライン
const pipe = (value: any, fns: Function[]) => {
return fns.reduce((acc, fn) => fn(acc), value);
};

`any` や `Function` を使った瞬間、TypeScriptのコンパイラはただの厄介な文法チェックツールに成り下がり、IDEの補完は沈黙し、実行時エラーの爆弾がコードベースに爆誕する。

フロントエンドの複雑な状態管理、非同期APIの合成、あるいは関数型パラダイムを取り入れたデータ処理において、「ある関数の戻り値の型を、次の関数の引数の型に厳密に追従させる(依存させる)」ことは、堅牢なアプリケーションを構築するための絶対条件だ。

今回は、TypeScriptの型システムを極限まで駆動させ、コンパイル時に完全に型安全が保証される関数パイプラインの設計パターンを、テクニカルリードの視点からロジカルに解説しよう。

—

1. なぜ「普通のジェネリクス」ではパイプラインの型が破綻するのか?

単純に「引数に渡す関数の配列」を受け取るジェネリック関数を書こうとすると、多くの開発者はここでつまずく。

// ありがちな失敗例
function badPipe(value: T, fns: ((arg: any) => any)[]): U {
return fns.reduce((acc, fn) => fn(acc), value);
}

これの何が問題か。
1. `fns` の要素が `(arg: any) => any` になっているため、途中でどんな型リークが起きても検知できない。
2. 戻り値の型 `U` が推論されず、手動でアノテーション(あるいは `as` キャスト)が必要になる。

私たちが目指すべきは、「1番目の関数の戻り値が2番目の引数になり、その戻り値が3番目の引数になり……」という連鎖(Chain)を、型レベルのタプル(Tuple)で完全に静的追跡することだ。

—

2. 実装:型安全なパイプライン・コンビネータ

TypeScriptの条件付き型(Conditional Types)と推論(`infer`)、そして可変長タプル型(Variadic Tuple Types)を駆使して、プロダクションレベルで耐えうるパイプライン関数を実装する。

以下のコードを見てほしい。コンパイラがどのように型を伝播させているか、その美しさを味わってほしい。

/

  • 前の関数の戻り値型(Prev)を受け取り、次の関数の引数型(Next)と厳密に一致させるための制約

/
type PipeFn = (arg: Prev) => Next;

/

  • タプルの先頭の戻り値型を抽出し、次の関数の引数型を連鎖的に決定する型パズル

/
type BuildPipeline =
TArgs extends readonly [
// 最初の関数を取り出す
infer First extends PipeFn,
// 残りの関数群を取り出す
…infer Rest extends readonly PipeFn[]
]
// 最初の関数の「戻り値型」を次の入力とし、再帰的に解決する
? BuildPipeline>
: TCurrent; // 関数がなくなったら現在の型を返す

/

  • 厳密な型安全性を担保するパイプライン関数

/
export function createPipeline any,
…PipeFn[]
]>(
initialValue: TInitial,
…fns: Tfns & BuildPipeline extends never ? never : Tfns
) {
return fns.reduce((acc, fn) => fn(acc), initialValue as any);
}

…と言いたいところだが、TypeScriptの型推論の限界(Contravariantな位置にある引数の推論問題)にぶつかるため、実務ではもう少し洗練された、オーバーロードまたは関数ビルダーパターンを用いるのが定石だ。

より実用的で、かつTypeScriptの限界を美しく突破するアプローチとして、「関数をカリー化して左から右へ合成する `flow`」の型定義を見てみよう。

—

3. プロダクションコード:極限まで型安全な `flow` と `pipe`

以下は、実務のフロントエンド開発(フォームのバリデーションパイプラインや、APIレスポンスの正規化処理など)でそのまま使える決定版のコードだ。

/

  • 0個の関数(そのまま返す)から最大5個までの関数を合成する型安全なパイプライン
  • (※実際には再帰型を使ってN個まで拡張可能だが、実用上は5〜8個程度に制限するのがビルドパフォーマンス上も安全)

/

// 1つの関数
export function pipe(a: A, ab: (arg: A) => B): B;
// 2つの関数
export function pipe(a: A, ab: (arg: A) => B, bc: (arg: B) => C): C;
// 3つの関数
export function pipe(a: A, ab: (arg: A) => B, bc: (arg: B) => C, cd: (arg: C) => D): D;
// 4つの関数
export function pipe(a: A, ab: (arg: A) => B, bc: (arg: B) => C, cd: (arg: C) => D, de: (arg: D) => E): E;

// 実装本体
export function pipe(initialValue: unknown, …fns: ((arg: any) => any)[]): unknown {
return fns.reduce((acc, fn) => fn(acc), initialValue);
}

// ==========================================
// 実践的な利用例(フロントエンド・データ処理)
// ==========================================

interface RawUser {
id: string;
raw_name: string;
created_at: string; // ISO string
}

interface FormattedUser {
id: number;
displayName: string;
joinedDate: Date;
}

// 1. 文字列のIDを数値にパースする関数
const parseId = (user: RawUser): { id: number; raw_name: string; created_at: string } => ({
…user,
id: parseInt(user.id, 10),
});

// 2. 名前のプロパティ名を変換し、トリムする関数
const formatName = (user: { id: number; raw_name: string; created_at: string }) => ({
id: user.id,
displayName: user.raw_name.trim().toUpperCase(),
created_at: user.created_at,
});

// 3. 日付文字列をDateオブジェクトに変換する関数
const parseDate = (user: { id: number; displayName: string; created_at: string }): FormattedUser => ({
id: user.id,
displayName: user.displayName,
joinedDate: new Date(user.created_at),
});

const rawInput: RawUser = {
id: “42”,
raw_name: ” tanaka taro “,
created_at: “2023-10-01T00:00:00.000Z”,
};

// — 型推論と実行の検証 —
const result = pipe(
rawInput,
parseId, // Input: RawUser -> Output: { id: number, raw_name: string, … }
formatName,// Input: 前者の出力 -> Output: { id: number, displayName: string, … }
parseDate // Input: 前者の出力 -> Output: FormattedUser
);

// result の型は完璧に `FormattedUser` として推論される!
console.log(result.joinedDate instanceof Date); // true

—

4. パフォーマンス上の注意点:型評価のコスト

チーフアーキテクトとして、ここで重大な警告をしておかなければならない。
TypeScriptの型システムはTuring Complete(チューリング完全)である。つまり、複雑すぎる条件付き型や深すぎる再帰型(Recursive Conditional Types)を書くと、TypeScriptの型チェッカー(tsserver)のCPU使用率が100%に張り付き、IDEの補完が数秒単位でフリーズするようになる。

避けるべきアンチパターン

  • 無限可変長タプルの再帰: 制限なく要素を受け取れるパイプライン型を深くまで評価させると、コンパイルタイムが劇的に悪化する。
  • 複雑なユニオン型の分配(Distributive Conditional Types): パイプラインの途中で広大なユニオン型が流れると、型空間が爆発(Combinatorial explosion)を起こす。

実務での最適解

1. オーバーロードの活用: 先ほど示したように、実用上必要な数(例: 4〜5段階の合成)までをオーバーロードで定義する。これにより、コンパイラは重い再帰計算を行わずに即座に型を確定できるため、IDEの動作が爆速になる。
2. 中間型の明示: 巨大なパイプラインを作るのではなく、意味のある粒度で中間変数に型を切り出す(DRYならぬReadable Types)。

—

5. まとめ

関数パイプラインにおける依存型の設計は、単なる「テクニックの自己満足」ではない。

  • 変更に強い: パイプラインの途中の関数の戻り値型を変更した瞬間、次以降の関数でコンパイルエラーが起き、バグの混入を未然に防ぐことができる。
  • ドキュメントとしての型: コードを読むだけで、データの流れ(Transform)が完全に把握できる。
  • IDEの完全な味方化: 開発者はストレスフリーなオートコンプリートの恩恵を受けられる。

型システムを「制限」と捉えるか、「最強の設計図」と捉えるかで、プロダクトの寿命は大きく変わる。ぜひ、明日のコードレビューから `any` や `Function` を駆逐し、この依存型パターンの設計を取り入れてみてほしい。

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