AIと育てる型定義 Part 5: UIとデータ層を繋ぐ『生きた仕様書』の完成のサムネイル

はじめに

UI コンポーネントの Props とデータ層の戻り値が別々の型で定義されていると、層をまたぐたびに変換コードが必要になります。
BlogPost 型を単一の場所から export して全層で共通参照すれば、変換コードが消えてデータフローが型で保証されます。

アプリケーションの各層で、似たような型を定義していませんか?

  • UIコンポーネントのPropsと、データ層の戻り値が各ファイルにローカルで定義されている。
  • データ構造はほぼ同じなのに、層をまたぐたびに型の変換や再定義が必要になり二重管理が発生している。
  • 「どこで定義された型が正解なのか」が曖昧で、どれを使えばいいか毎回調べている。

この記事をお勧めしない人

  • UI層とデータ層の型が疎結合であれば、定義が散らばっていても問題ないと考えている人。
  • 型定義を一箇所に集約することによる、プロジェクト全体の透明性向上に価値を感じない人。
  • 単なる「コードの整理」を超えた、設計上の「契約」としての型定義に関心がない人。

もし一つでも当てはまらないなら、読み進める価値があるかもしれません。

型が各層にバラバラに定義されたままだと

  • 型変換コードが層の境界に蓄積し、データフローのどこかで as unknown as X が生まれ始める。
  • 一方の型を変えたとき、もう一方の型との不整合がコンパイルを通り抜けてランタイムエラーになる。
  • AI に型修正を頼むと変換コードを追加する提案が返ってきて、問題が大きくなる。

生きた仕様書の完成という明るい未来

  • この記事を読めば、UIからデータ層までを一貫した型で繋ぎ、プロジェクトの「生きた仕様書」を完成させる手法が手に入る。
  • 具体的には、各層に散らばる型定義を中央に統合し、データフローを型レベルで完全保証する設計図を手に入れられる。
  • この方法は、本ブログのリファクタリング最終章として、全層を網羅する型体系を確立するために実証済みである。

私も同じでした

このブログの BlogPost 型が UI コンポーネント側とデータアクセス層側でそれぞれ定義されており、フィールドが微妙にズレていました。
単一の BlogPost 型を app/types/domain/ から export し、全層で共通参照することで変換コードが消えました。このシリーズの最終章 Part 5 です。

📝 概要

AIとの協調リファクタリングシリーズ、ついに最終回です。私たちはこれまで、4つのステップを通じて型定義を体系的に整理してきました。

今回は、この旅の総仕上げとして、UIコンポーネント層とデータアクセス層にローカルで定義されていた最後の型定義を、プロジェクトの中央型定義に統合します。これにより、型定義ファイルは名実ともにプロジェクトの 「生きた仕様書」 として完成します。

私が達成した成果:

  • UIとデータ層の完全な型統合 - コンポーネントのProps型とデータ層の戻り値型を中央定義に集約
  • アプリケーション全体のデータフロー保証 - Remixのloaderとコンポーネント間のデータ受け渡しを型レベルで完全保証
  • 生きた仕様書の完成 - データモデルからUI仕様まで、全層を網羅する単一の型定義体系を確立

この記事では、AIとの対話を通じて、各層に散らばっていた型定義を体系的に統合し、保守性と信頼性を劇的に向上させるまでの全プロセスを解説します。

🔧 実装の全詳細:UIとデータの『契約』を定義する

では、実際にどのような型定義が各層に散らばっていたのか、それをどう統合したのか、そしてなぜこの統合がアプリケーションの信頼性向上に直結するのか。具体的な型定義の変遷、AIとの設計相談の全内容、そして実装コードの詳細を公開します。

最後の課題:各層に残るローカルな型定義

これまでのリファクタリングを経て、コードベースはかなりクリーンになりました。しかし、まだいくつかの型が、その本来あるべき場所ではないファイルにローカルで定義されていました。

  1. データアクセス層の戻り値型 : データアクセス層にありながら、UI層でのページネーション計算を意識したプロパティを持っていました。責務が曖昧な状態です。
  2. 記事一覧ページのProps型 : コンポーネント内に定義されたProps型。その実態は、loaderが返す複数のデータをまとめただけのものでした。
  3. 記事詳細ページのProps型 : コンポーネント内に定義されたProps型。MarkdownがHTMLに変換された後の、UI表示専用のデータ構造を持っていました。

これらは、UIとデータを繋ぐ重要な「契約」でありながら、ローカルに定義されているため、プロジェクト全体の見通しを妨げる最後の要因となっていました。

AIとの設計相談:『生きた仕様書』の完成へ

最後の仕上げについて、AIアシスタントと最終確認を行いました。

🧑‍💻 (自分): 「UIコンポーネントのPropsやデータ層の戻り値の型が、まだ各ファイルに散らばっている。これらも中央の型定義に集約すべきだろうか?」

🤖 (AI): 「その通りです。それこそが、このリファクタリングの最終目標です。UIコンポーネントが期待するデータの形状(Props)や、データ層が提供するデータの形状(戻り値)を中央の型定義に集約することで、そのファイルは データモデルからUIの仕様までを網羅した『生きた仕様書』 になります。これにより、Remixの loader とコンポーネント間のデータの受け渡しが型レベルで保証され、プロジェクト全体の信頼性が劇的に向上します。」

この対話により、今回の作業が単なる整理ではなく、 アプリケーションのデータフロー全体を型で定義し、保証する という、極めて重要なステップであることが確認できました。

Step 1: UIとデータ層で使われる型を共通定義

まず、中央の型定義ファイルに、各層で必要となる新しい型を定義しました。

/**
 * 記事一覧ページ(PostsSection)で利用するすべてのデータ
 */
export interface PostsPageData {
  posts: PostSummary[];
  pagination: Pick<PaginationInfo, 'currentPage' | 'totalPages'>;
  availableFilters: AvailableFilters;
  selectedFilters: FilterOptions;
}

/**
 * 記事詳細ページでレンダリングするための記事データ
 * Post型のcontent(markdown)をhtmlContent(html)に置き換えたもの
 */
export type RenderedPost = Omit<Post, 'content' | 'summary' | 'testOnly'> & {
  htmlContent: string;
};

RenderedPost では、TypeScriptの Omit& を組み合わせ、基底の Post 型から不要なプロパティを除外し、UI表示に必要な htmlContent を追加する形で派生させています。これにより、元の型との関係性を保ちつつ、UI専用のデータモデルを安全に定義できました。

Step 2: 各コンポーネントのPropsを共通型に置き換え

次に、各コンポーネントにローカルで定義されていたProps型を削除し、新しく定義した共通の型を参照するように変更しました。

- interface PostsSectionProps {
-   posts: PostSummary[];
-   pagination: {
-     currentPage: number;
-     totalPages: number;
-   };
-   // ... and more
- }
- const PostsSection: React.FC<PostsSectionProps> = ({...}) => {
+ import type { PostsPageData } from '~/specs/blog/types';
+
+ const PostsSection: React.FC<PostsPageData> = ({...}) => {

これにより、Remixの loader が返すデータ構造と、コンポーネントが受け取るデータ構造が単一の型で完全に一致し、見通しが大幅に改善されました。

結果と考察:コードが語る仕様書

この5回にわたるリファクタリングの旅を経て、私たちのコードベースは大きく変貌しました。中央の型定義ファイルは、もはや単なる型の寄せ集めではありません。

  • データモデルの定義 - 記事データの基本構造
  • ビジネスロジックの入力/出力 - フィルタリングやページネーションの型
  • サイト全体の構成情報 - ブログ設定やメニュー項目
  • UIコンポーネントのデータ契約 - ページコンポーネントが受け取るデータ構造

これらすべてを内包し、 アプリケーションの振る舞いそのものを定義する「生きた仕様書」 となったのです。

AIとの対話を通じて、場当たり的な修正ではなく、設計原則に基づいた体系的な改善を続けることができました。その結果、保守性や信頼性が向上しただけでなく、コード自体がプロジェクトの設計思想を雄弁に語る、理想的な状態に近づいたと言えるでしょう。

型で「生きた仕様書」を作ったのと同様に、E2EテストのセレクタもextractTestIdパターンでspec.yamlから動的に取得する仕組みを整えています。型・YAML・テストセレクタが同一のSSOTを参照することで、UIを変更してもテストが壊れない構造が完成します。

このシリーズの記事

あなたのリポジトリへの適用

どちらを採用しますか?(1つ選択)

SEPARATION|責務を分離

UIのPropsとデータ層の戻り値が共通のドメイン型で統合されているか確認する

調査:

  1. UIコンポーネントのPropsの型定義とデータ層の戻り値の型が共通のドメイン型を使用しているか確認せよ
  2. 層をまたぐたびに型変換コードが書かれている箇所を特定せよ
  3. 新しく追加されたコンポーネントがドメイン型を正しく継承しているか確認せよ

この内容をClaude Codeに貼り付けて実行します

SIMPLICITY|シンプルに管理

層をまたぐ変換コードが存在しないか確認する

調査:

  1. ローダー・コンポーネント・APIの間でデータ変換が必要になっているケースを特定せよ
  2. 「生きた仕様書」として機能するドメイン型が追加後も適切に拡張されているか確認せよ
  3. 変換コードの追加が必要になったのはドメイン型の設計問題か、実装側の問題かを確認せよ

この内容をClaude Codeに貼り付けて実行します

外部コードのローカル実行にはリスクがあります。ブラウザ環境での実行を推奨します。