TypeScriptの型システムを「パイプライン」として極める ― 関数合成の型推論を壊さないための極意
TypeScriptで開発をしていると、関数を連結する「パイプライン」処理に出くわすはずだ。`pipe(f, g, h)` のような関数を実装する際、多くのエンジニアが `(…fns: Function[]) => any` といった「型を放棄した」実装に逃げてしまう。
だが、それではTypeScriptの強力な型推論を殺しているのと同じだ。今日は、コンパイラを唸らせるほど美しく、かつ実務で堅牢性を保証する「パイプライン型定義」の深淵を紐解こう。
—
なぜ「`any`」や「`Function`」への逃避が致命的なのか
まず、コードレビューで最も見たくないのがこれだ。
// 悪い例:型安全性が皆無のパイプライン
const pipe = (val: any, …fns: Function[]) => fns.reduce((acc, fn) => fn(acc), val);
これでは、`pipe(1, (n: number) => n.toString(), (s: string) => s.length)` と書いた瞬間に型チェックが機能しなくなる。引数と戻り値のミスマッチも、コンパイル時に検知できない。我々が目指すべきは、「入力型から始まり、最後の出力型までが連鎖的に推論される」完璧な関数シグネチャだ。
—
達成すべきゴール:ジェネリクスによる連鎖推論
パイプラインの型定義には「可変長引数」と「ジェネリクスによる再帰的な型評価」が必要になる。以下に、実務レベルでそのまま使える堅牢なパイプライン定義を示す。
実装コード:型推論を維持するパイプライン
/
- 関数の合成パイプライン
- 最初の関数の引数が全体の入力となり、最後の関数の戻り値が全体の出力となる
/
type Func = (arg: any) => any;
export const pipe =
initialValue: T,
…fns: [
(arg: T) => any,
…Array<(arg: any) => any>,
(arg: any) => R
]
): R => {
return fns.reduce((acc, fn) => fn(acc), initialValue);
};
// 使用例
const add = (a: number) => a + 10;
const toString = (n: number) => `Result: ${n}`;
const getLength = (s: string) => s.length;
// 型推論が完璧に機能する
// result の型は number となり、コンパイラはステップごとの型整合性を追跡する
const result = pipe(5, add, toString, getLength);
console.log(result); // 13
この実装のポイント
1. ジェネリクス `T` と `R`: 最初の引数 `T` と最終的な戻り値 `R` を定義することで、パイプライン全体をブラックボックス化せず、外部から型を注入できる。
2. タプル型の活用: `[ (arg: T) => any, …Array<(arg: any) => any>, (arg: any) => R ]` と定義することで、「少なくとも一つ以上の関数が必要であり、最初と最後が型的に整合していなければならない」という制約をコンパイル時に強制している。
—
パフォーマンス上の注意点:コンパイラの限界
ここで一つ注意が必要だ。TypeScriptの型推論には計算量(Depth)の制限がある。あまりに複雑な型関数を再帰的に定義しすぎると、IDEのレスポンスが極端に低下する。
- 過度な抽象化は避ける: `pipe` を10個、20個と連結するケースは稀だ。もしパイプラインが長大になりすぎる場合は、型定義の複雑さを増すのではなく、途中で `interface` を挟んで型を明示的に分割することを推奨する。
- tscの負荷: `Array
` を乱用すると、推論コストが跳ね上がる。可能な限り `any` を排除し、ジェネリクスで境界を絞ることが、結果としてビルド時間の短縮に繋がる。
—
実務での応用:APIレスポンスの変換パイプライン
例えば、外部APIから取得したデータを整形し、バリデーションを通し、ドメインモデルへ変換する処理は、このパイプラインパターンの主戦場だ。
interface RawData { id: string; raw_value: number; }
interface DomainModel { id: string; value: number; }
const parseData = (d: RawData): { id: string; val: number } => ({ id: d.id, val: d.raw_value });
const validate = (d: { id: string; val: number }) => {
if (d.val < 0) throw new Error("Invalid value");
return d;
};
const transform = (d: { id: string; val: number }): DomainModel => ({ id: d.id, value: d.val });
// 堅牢なパイプライン
const processData = (data: RawData) => pipe(data, parseData, validate, transform);
このように、小さな関数を積み重ね、型で縛ることで、テストが極めて容易なコードベースが出来上がる。
—
最後に:型は「ドキュメント」以上の存在である
TypeScriptにおける型定義は、単なるエラーチェックのツールではない。それは「コードがどうあるべきか」という設計思想そのものだ。
パイプラインの型を疎かにすることは、将来の自分自身に「このデータが何であるか」をいちいち追跡させる負債を残すことに他ならない。今日紹介したパターンを自分のコードベースに組み込み、型推論という最強の味方を完全に手懐けてみてほしい。
型が正しく定義されていれば、テストを書く回数は劇的に減る。なぜなら、コンパイラが既に「正しい道」を照らしてくれているからだ。