【実務・中級編】関数型におけるプロパティ定義:関数をオブジェクトとして扱う – TypeScript コア・型システムの基礎解析バイブル

TypeScriptを掌握する:関数を「オブジェクト」として定義する極限の型設計

フロントエンドのコードベースを見ていると、時折「関数にプロパティを生やす」という設計に出くわすことがあるだろう。例えば、`React.memo`や`lodash`のユーティリティ、あるいは特定の状態を持ったイベントハンドラなどだ。

これを安易に `any` でキャストして逃げていないか? あるいは、クラスにすべきところを無理やり関数で実装して型定義に苦しんでいないか?

今日は、TypeScriptの型システムを深く理解し、「関数オブジェクト」を安全かつエレガントに定義する手法について、コアの観点から伝授する。

—

なぜ「関数オブジェクト」が必要なのか?

TypeScriptにおいて関数は第一級オブジェクトである。つまり、プロパティやメソッドを追加できる。しかし、型システム上では「関数」として定義した瞬間に、その追加プロパティは隠蔽される。

単なる「関数」として呼び出したい時と、「設定値やメタデータ」にアクセスしたい時。この両立こそが、API設計における究極の柔軟性だ。

アンチパターン:anyによる汚染

// 悪い例:anyの多用は型システムの放棄
const fn: any = () => {};
fn.meta = { id: 1 };

これではTypeScriptを使う意味がない。コンパイル時のチェックは無効化され、実行時にプロパティが存在しないことによる `TypeError` を引き起こすリスクを抱えることになる。

—

推奨パターン:インターフェースの「交差型」による定義

関数にプロパティを付与する最も堅牢な方法は、「関数シグネチャを持つインターフェース」と「プロパティ定義」を交差型(Intersection Types)で合成することだ。

実践的なプロダクションコード例

非同期APIの呼び出しを管理しつつ、リトライ回数やキャッシュのメタデータを持つ「拡張可能な関数」を例に挙げる。

/

  • 関数としての振る舞い(Call Signature)と
  • オブジェクトとしてのプロパティを分離して定義する

/
interface AsyncAction {
(input: string): Promise; // 関数としての型
retryCount: number; // 追加プロパティ
cancel: () => void; // 補助的なメソッド
}

const createAction = (): AsyncAction => {
// 関数本体を作成
const fn = async (input: string) => {
console.log(`Processing: ${input}`);
};

// オブジェクトとしてプロパティを付与
// ここで型アサーションを行うが、スコープは関数内に限定する
const action = fn as AsyncAction;
action.retryCount = 0;
action.cancel = () => console.log(‘Cancelled’);

return action;
};

// 利用側
const myAction = createAction();
myAction(‘data.json’); // 関数呼び出し
console.log(myAction.retryCount); // オブジェクトとしてのアクセス

なぜこの実装が「美しい」のか?

1. 関数の本質を維持: `AsyncAction` 型は `Callable` であることが保証されているため、呼び出し元はそれが関数であることを意識せずに使える。
2. カプセル化: `createAction` の内部で `as` キャストを完結させている。外部の利用者はこのキャストの存在を知る必要がない。
3. 型安全性: コンパイラは `myAction.unknownProp` を即座に弾く。メンテナンス時にプロパティを変更しても、コンパイルエラーが確実に警告してくれる。

—

パフォーマンスと設計上の注意点

この設計を採用する際、コードレビューで必ず指摘すべきポイントが2つある。

1. 「関数」か「クラス」かを見極める

関数オブジェクトに状態(state)を持たせすぎてはいないか? もしそのプロパティが頻繁に書き換わり、複雑なロジックを伴うなら、それは関数ではなくクラス(Class)であるべきだ。
関数オブジェクトは「イミュータブルに近い設定値」や「付随するメタデータ」を保持するのに向いている。

2. コンパイラの評価コストを意識する

TypeScriptの型システムは、交差型(`&`)を解決する際、内部で構造的型付けの検証を行う。過度に巨大なインターフェースを交差させ続けると、IDEの補完速度やコンパイル時間に影響が出る。
「関数オブジェクト」の型定義は、あくまでその関数の利用に直結する最小限のメンバに留めること。 関連性の薄いデータは、別のオブジェクトとして管理し、関数の戻り値として含める設計を検討すべきだ。

—

結論:型は「意図」を語るべきだ

関数をオブジェクトとして扱うというテクニックは、一見するとTypeScriptの柔軟性に甘えているように見えるかもしれない。しかし、正しく型を定義すれば、それは「この関数にはメタデータが必要である」という設計意図をコードに刻む強力な手段に変わる。

「なぜ `any` ではなくインターフェースなのか?」
「なぜクラスではなく関数なのか?」

この問いにロジカルに答えられるエンジニアだけが、大規模なフロントエンドアーキテクチャを堅牢に保つことができる。今日から、あなたの書くその関数に、正しい「型という名前の骨格」を与えてみてほしい。

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