Grandream
TypeScriptのジェネリクス型制約を活用してマルチエンティティ対応コンポーネントを安全に実装する
はじめに:複数エンティティを扱うコンポーネントの課題
実務のフロントエンド開発において、機能拡張に伴い「1つのコンポーネントで複数のエンティティ(データモデル)を扱いたい」という要件は頻出します。たとえば、最初は「ユーザー(User)」だけを扱っていたパンくずリストや詳細画面のコンポーネントを、新しく追加された「記事(Article)」のデータにも対応させるといったケースです。
このような要件に対し、安易にUnion型を使って User | Article のように型を拡張すると、思いがけないビルドエラーに直面することがあります。特に、エラーハンドリングのために導入している Result 型(成功/失敗のラッパー型)などと組み合わせた場合、型の不整合を解消できず、最終的に any 型や as キャストでごまかしてしまうという事態に陥りがちです。
本記事では、複数エンティティを扱うコンポーネント設計において頻出する「Result型の型不一致エラー」を例に、ジェネリクスとDiscriminated Union(判別可能なユニオン)を活用して根本的に解決する実装パターンを解説します。
問題の本質:異なるID型による不一致とUnion型の限界
今回の課題の根本的な原因は、エンティティ間で「同一名のプロパティでも型が異なる」ことです。 具体例として、User と Article のインターフェースを見てみましょう。

// ユーザーエンティティ:idが文字列
interface User {
id: string;
name: string;
email: string;
}
// 記事エンティティ:idが数値
interface Article {
id: number;
name: string;
category: string;
}
ここで、成功か失敗かを表現する Result<T, E> 型を使用していると仮定します。
type Result<T, E = Error> =
| { type: "ok"; value: T }
| { type: "error"; error: E };
パンくずリスト(Breadcrumbs.tsx)などの共通コンポーネントで両方のエンティティを受け取ろうとして、安易に Result<User | Article, NotFoundError> という型を定義してしまうと、TypeScriptのコンパイラは以下のようなエラーを吐き出します。
Type '{ type: "ok"; value: User } | { type: "ok"; value: Article }' is not assignable to type 'Result<User, NotFoundError>'.
なぜなら、User.id は string であり、Article.id は number だからです。コンポーネント内部で value.id にアクセスしようとした際、型が確定していない状態では安全な操作が担保できず、TypeScriptは厳密にエラーとして報告します。この状況をUnion型だけで解決しようとすると、無理なキャストが必要になり、型安全性が完全に崩れてしまいます。
解決へのアプローチ:ジェネリクスとResult型の導入
結論から述べると、無理に Result<User | Article, E> という1つの型に押し込むのではなく、コンポーネント自体をジェネリクス化し、利用側で型を決定するアプローチが最適です。
ジェネリクス(Generics)は、型をパラメータとして受け取る仕組みです。共通コンポーネントに型引数 <T> を持たせることで、User が渡された時は Result<User, E> として振る舞い、Article が渡された時は Result<Article, E> として振る舞う、柔軟かつ安全なコンポーネントを作成できます。
さらに、エンティティの「種類」を明示的に判別できるよう、Discriminated Union(判別可能なユニオン)の設計を取り入れます。各エンティティにリテラル型の識別子(たとえば entityType: "user" や entityType: "article")を持たせることで、コンポーネント内で安全な分岐処理(型ガード)が可能になります。
実装手順:Discriminated Unionを用いた型安全なコンポーネント設計
では、実際にジェネリクスとDiscriminated Unionを組み合わせて、安全な共通コンポーネントを実装する手順を見ていきましょう。

1. エンティティに識別子(Discriminator)を追加する
まず、各インターフェースに共通のプロパティとして識別子を持たせます。
interface User {
entityType: "user"; // 識別子
id: string;
name: string;
}
interface Article {
entityType: "article"; // 識別子
id: number;
name: string;
}
// 共通で扱いたいエンティティのUnion
type Entity = User | Article;
2. ジェネリクスを用いたコンポーネントの定義
次に、Reactコンポーネントをジェネリクスを使って定義します。型引数 T が Entity 型を満たすように制約(extends Entity)を設けます。
import React from 'react';
// エラー型の定義
class NotFoundError extends Error {}
type Result<T, E = Error> =
| { type: "ok"; value: T }
| { type: "error"; error: E };
// コンポーネントのPropsにジェネリクスを適用
interface BreadcrumbsProps<T extends Entity> {
result: Result<T, NotFoundError>;
}
// ジェネリックコンポーネントの実装
export const Breadcrumbs = <T extends Entity>({ result }: BreadcrumbsProps<T>) => {
if (result.type === "error") {
return <div>データが見つかりませんでした</div>;
}
const entity = result.value;
// Discriminated Unionによる安全な分岐
if (entity.entityType === "user") {
// ここでは entity は User 型として推論されるため、id は string
return <div>ユーザー: {entity.name} (ID: {entity.id.toUpperCase()})</div>;
}
if (entity.entityType === "article") {
// ここでは entity は Article 型として推論されるため、id は number
return <div>記事: {entity.name} (ID: {entity.id.toFixed(0)})</div>;
}
return null;
};
この実装により、Result<User> か Result<Article> のいずれかが渡されても、コンポーネント内部で entityType を使って安全に型を絞り込むことができます。entity.id.toUpperCase() のように string 固有のメソッドを呼び出しても、型エラーは発生しません。
ESLint対応:no-unsafe-function-type の解消方法
ジェネリックなコンポーネントを設計する際、コールバック関数をPropsとして受け取ることも多いでしょう。このとき、古いコードベースでよく見かける Function 型を使ってしまうと、最新のESLintルールである @typescript-eslint/no-unsafe-function-type に違反します。
Function 型は、引数や戻り値の型を一切制約しないため、TypeScriptの恩恵を受けられません。ジェネリクスを活用しているのなら、コールバック関数のシグネチャも厳密に定義すべきです。
修正前(ESLint違反):
interface BreadcrumbsProps<T extends Entity> {
result: Result<T, NotFoundError>;
onNavigate: Function; // 警告が発生する
}
修正後(明示的な関数シグネチャ):
interface BreadcrumbsProps<T extends Entity> {
result: Result<T, NotFoundError>;
onNavigate: (entityId: T['id']) => void; // 安全で厳密な定義
}
このように、引数の型も T['id'](Tのidプロパティの型)として定義することで、User が渡された場合は string、Article が渡された場合は number を引数に取る関数として自動的に推論されます。これにより、リンターの警告を解消しつつ、完全な型安全性を実現できます。
まとめ:拡張に強いフロントエンドの型設計
本記事では、異なるID型を持つ複数エンティティを共通コンポーネントで扱う際に発生する Result 型の不一致エラーを、ジェネリクスとDiscriminated Unionを用いて解決するアプローチを解説しました。
- Union型だけでごまかさない:
idがstringとnumberのように競合する場合、Union型では安全なアクセスが担保できません。 - ジェネリクスで型を外から注入する: コンポーネントをジェネリクス化し、引数の型に応じた推論を効かせるのがベストプラクティスです。
- Discriminated Unionで安全に分岐: 共通の識別子(
entityTypeなど)を持たせることで、コンポーネント内部で厳密な型ガードが可能になります。
この設計パターンの最大のメリットは、将来エンティティが増えても安全に拡張できる点です。新しいエンティティを追加しても、コンパイル時点で網羅性チェックや型エラーが検知されるため、実行時エラーを未然に防ぐことができます。型安全な設計を基盤に、堅牢なフロントエンド開発を進めていきましょう。
Grandream
株式会社グランドリーム
AI・システム開発のプロフェッショナルチームです。AIエージェント・業務自動化・Webシステム開発などを手がけています。
