TypeScriptでマイクロフロントエンドを設計する 分割と共有型を運用で破綻させない要点
複数チームでフロントエンドを分割開発しているプロジェクトで、「共有型の変更がいつの間にか他モジュールを壊していた」「バージョン違いで型の不整合が起きた」といった問題に悩んだ経験はないでしょうか。この記事では、TypeScript 環境でマイクロフロントエンドを設計する際に、共有型の管理とバージョン互換をどう考えるかを整理し、分割しても壊れない運用を実務視点で解説します。
共有型の管理手法と比較
マイクロフロントエンドにおける型定義ファイルの共有方法には、大きく 3 つのアプローチがあります。
| 手法 | 型安全性 | デプロイ独立性 | 運用コスト | 向いているケース |
|---|---|---|---|---|
| 共有型パッケージ(npm) | ◎ | △ | 中 | 型変更頻度が低い |
| ビルド時型生成(Module Federation) | ○ | ◎ | 低 | 独立デプロイ重視 |
| Contract-First(API スキーマ) | ◎ | ◎ | 高 | API 境界が明確 |
それぞれの詳細は後述しますが、実際に試したところ「どれか一つに統一」ではなく、境界の性質に応じて使い分けるのが現実的でした。
検証環境
- OS: macOS Sonoma 14.7
- Node.js: 24.11.0 LTS (Krypton)
- TypeScript: 5.9.3
- 主要パッケージ:
- @module-federation/enhanced: 0.8.4
- @rspack/core: 1.7.2
- zod: 3.24.1
- react: 19.0.0
- 検証日: 2026 年 1 月 19 日
マイクロフロントエンドで型安全が崩れる背景
マイクロフロントエンドとは、フロントエンドを独立した小さなモジュールに分割し、各チームが個別に開発・デプロイできるアーキテクチャです。バックエンドのマイクロサービスと同様の考え方をフロントエンドに適用したものです。
TypeScript の静的型付けは、単一リポジトリでは強力に機能します。しかし、モジュールを分割して独立デプロイする構成になると、以下の問題が発生しやすくなります。
動的インポートによる型情報の欠落
Module Federation などでリモートモジュールを読み込む場合、ビルド時に型情報が存在しないことがあります。
typescript// 型情報が取得できない例
const RemoteComponent = React.lazy(
() => import('userModule/UserProfile')
);
// props の型が any になり、型安全が失われる
<RemoteComponent userId={123} /> // 本当は string なのに number を渡してもエラーにならない
つまずきやすい点:IDE 上ではエラーが出ないため、本番環境で初めて不具合に気づくケースが多いです。
型定義のバージョン不整合
共有型パッケージを使う場合、各モジュールが異なるバージョンを参照していると、実行時に型の不整合が発生します。
typescript// shared-types@1.0.0 を使用するモジュール A
interface User {
id: string;
name: string;
}
// shared-types@2.0.0 を使用するモジュール B(email が必須に)
interface User {
id: string;
name: string;
email: string; // 追加されたフィールド
}
// モジュール A からモジュール B にユーザー情報を渡すと
// email が undefined で実行時エラー
共有型管理が破綻する典型的な課題
ここでは、実務で実際に起きた問題とその原因を整理します。
暗黙的な破壊的変更
共有型パッケージでフィールドを追加しただけのつもりが、他モジュールの動作を壊すケースがあります。
typescript// 変更前
interface Order {
id: string;
items: OrderItem[];
status: "pending" | "completed";
}
// 変更後(status に新しい値を追加)
interface Order {
id: string;
items: OrderItem[];
status: "pending" | "processing" | "completed"; // processing を追加
}
型定義上は後方互換に見えますが、消費側で網羅的な switch 文を書いていた場合、processing のハンドリングが漏れてデフォルトケースに落ちることがあります。
検証の結果、このような「見かけ上の後方互換」が最も事故につながりやすいことがわかりました。
デプロイタイミングのずれ
独立デプロイがマイクロフロントエンドの利点ですが、型の整合性という観点ではリスクになります。
以下の図は、デプロイタイミングのずれによって型不整合が発生する流れを示しています。
mermaidsequenceDiagram
participant Types as 共有型
participant ModA as モジュール A
participant ModB as モジュール B
participant User as ユーザー
Types->>Types: v2.0.0 リリース
ModA->>ModA: v2.0.0 で再ビルド・デプロイ
Note over ModB: まだ v1.0.0 のまま
User->>ModA: アクセス
ModA->>ModB: データ連携
ModB-->>User: 型不整合でエラー
この図のように、共有型の更新後にすべてのモジュールが同時にデプロイされるとは限りません。
CI/CD パイプラインの複雑化
共有型パッケージを更新するたびに、依存するすべてのモジュールを再ビルド・再デプロイする必要があると、CI/CD の設計が複雑になります。
業務で問題になったのは、共有型の軽微な修正でも全モジュールのデプロイが走り、リリースサイクルがモノリス時代より遅くなったケースでした。
型共有の 3 つの手法と判断基準
ここからは、共有型の管理手法を詳しく比較し、どの場面でどの手法を選ぶべきかを解説します。
共有型パッケージ(npm パッケージ)
型定義を専用の npm パッケージとして管理し、各モジュールが依存として参照する手法です。
typescript// @company/shared-types パッケージ
export interface User {
id: string;
name: string;
email: string;
role: UserRole;
}
export type UserRole = "admin" | "member" | "guest";
export interface ApiResponse<T> {
success: boolean;
data: T;
errors?: ApiError[];
}
json// 各モジュールの package.json
{
"dependencies": {
"@company/shared-types": "^2.0.0"
}
}
メリット
- ビルド時に完全な型チェックが効く
- IDE の補完・ナビゲーションが機能する
- 型の変更履歴がバージョンとして追跡できる
デメリット
- パッケージ更新のたびに依存モジュールの再ビルドが必要
- バージョン固定しないと意図しない更新が入る
- プライベートレジストリの運用コストがかかる
つまずきやすい点:
^でバージョン指定すると、CI 環境と本番環境で異なるバージョンが使われることがあります。
ビルド時型生成(Module Federation 2.0)
Module Federation 2.0 の動的型ヒント機能を使い、リモートモジュールの型定義をビルド時に自動生成する手法です。
typescript// rspack.config.ts
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
export default {
plugins: [
new ModuleFederationPlugin({
name: "user_module",
exposes: {
"./UserProfile": "./src/components/UserProfile",
},
// 型定義の自動生成を有効化
dts: {
generateTypes: true,
typesFolder: "@mf-types",
consumeTypes: true,
},
}),
],
};
typescript// ホストアプリ側(型が自動的に解決される)
import UserProfile from 'user_module/UserProfile';
// props の型が正しく推論される
<UserProfile userId="123" onUpdate={handleUpdate} />
メリット
- 各モジュールの独立デプロイを維持できる
- 型定義パッケージの手動管理が不要
- 実装と型定義の乖離が起きにくい
デメリット
- Module Federation 2.0 以降が必要
- 開発サーバー起動時にリモートモジュールが起動している必要がある
- 型の互換性チェックは実行時まで遅延する
Contract-First 開発(API スキーマ)
Zod や JSON Schema などでスキーマを定義し、そこから TypeScript 型と実行時バリデーションを自動生成する手法です。
typescript// contracts/user.contract.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "member", "guest"]),
});
// TypeScript 型を自動生成
export type User = z.infer<typeof UserSchema>;
// 実行時バリデーション付きの関数
export function parseUser(data: unknown): User {
return UserSchema.parse(data);
}
typescript// 消費側での使用
import { parseUser, User } from "@company/contracts";
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// 実行時に型が検証される
return parseUser(data);
}
メリット
- コンパイル時と実行時の両方で型安全
- API 境界での不整合を即座に検出できる
- スキーマからドキュメント生成も可能
デメリット
- スキーマ定義の学習コストがかかる
- バリデーションのランタイムオーバーヘッドがある
- 内部コンポーネント間の通信には過剰な場合がある
バージョン互換を壊さない設計指針
ここでは、共有型を変更する際にバージョン互換を維持するための具体的な指針を説明します。
追加は許容、削除・変更は慎重に
セマンティックバージョニングの原則に従い、フィールドの追加はマイナーバージョン、削除や型変更はメジャーバージョンで管理します。
typescript// v1.0.0
interface User {
id: string;
name: string;
}
// v1.1.0(フィールド追加は後方互換)
interface User {
id: string;
name: string;
email?: string; // optional として追加
}
// v2.0.0(フィールド削除は破壊的変更)
interface User {
id: string;
displayName: string; // name を displayName に変更
email: string; // required に変更
}
Union 型の拡張には注意
先述のとおり、Union 型への値追加は型上は後方互換でも、消費側の実装によっては破壊的になります。
typescript// 安全な拡張パターン
type OrderStatus = "pending" | "processing" | "completed" | (string & {}); // 将来の拡張を許容するエスケープハッチ
// 消費側は unknown status を考慮した実装にする
function renderStatus(status: OrderStatus) {
switch (status) {
case "pending":
return "保留中";
case "processing":
return "処理中";
case "completed":
return "完了";
default:
return `不明 (${status})`; // フォールバック
}
}
非推奨フィールドの段階的削除
フィールドを削除する際は、@deprecated で非推奨を示してから、次のメジャーバージョンで削除します。
typescriptinterface User {
id: string;
/** @deprecated displayName を使用してください */
name?: string;
displayName: string;
}
以下の図は、非推奨フィールドの段階的削除フローを示しています。
mermaidflowchart LR
V1["v1.x<br/>name 使用"] --> V2["v2.x<br/>name deprecated<br/>displayName 追加"]
V2 --> V3["v3.x<br/>name 削除"]
V2 -.-> |移行期間| V2
この図のように、一定の移行期間を設けることで消費側の対応時間を確保できます。
分割しても壊れない CI/CD 設計
CI/CD パイプラインの設計も、マイクロフロントエンドの安定運用には欠かせません。
型互換チェックの自動化
共有型パッケージを更新する際、依存モジュールとの互換性を CI で自動チェックします。
yaml# .github/workflows/type-compatibility.yml
name: Type Compatibility Check
on:
pull_request:
paths:
- "packages/shared-types/**"
jobs:
check-compatibility:
runs-on: ubuntu-latest
strategy:
matrix:
module: [user-module, order-module, host-app]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build shared-types (PR version)
run: yarn workspace @company/shared-types build
- name: Type check ${{ matrix.module }}
run: yarn workspace ${{ matrix.module }} type-check
変更影響範囲の自動検出
どのモジュールが変更の影響を受けるかを自動検出し、必要なモジュールだけ再ビルドします。
typescript// tools/detect-affected.ts
import { execSync } from "child_process";
interface AffectedModule {
name: string;
reason: string;
}
function detectAffectedModules(changedPackage: string): AffectedModule[] {
const dependencyGraph = JSON.parse(
execSync("yarn workspaces info --json").toString(),
);
const affected: AffectedModule[] = [];
for (const [name, info] of Object.entries(dependencyGraph)) {
const deps = (info as any).workspaceDependencies || [];
if (deps.includes(changedPackage)) {
affected.push({
name,
reason: `${changedPackage} に依存`,
});
}
}
return affected;
}
// 使用例
const affected = detectAffectedModules("@company/shared-types");
console.log("影響を受けるモジュール:", affected);
段階的デプロイ戦略
全モジュールを一度にデプロイするのではなく、影響範囲の小さいモジュールから段階的にデプロイします。
yaml# 段階的デプロイの例
stages:
- name: canary
modules: [user-module]
traffic: 5%
duration: 30m
- name: partial
modules: [user-module, order-module]
traffic: 25%
duration: 1h
- name: full
modules: [user-module, order-module, host-app]
traffic: 100%
実装例:Module Federation 2.0 と型共有
ここでは、Module Federation 2.0 を使った具体的な実装例を示します。
プロジェクト構成
rubyproject-root/
├── packages/
│ ├── host-app/ # ホストアプリ
│ ├── user-module/ # ユーザー管理モジュール
│ ├── order-module/ # 注文管理モジュール
│ └── shared-contracts/ # 共有スキーマ(Zod)
└── package.json
共有スキーマの定義
Contract-First アプローチで、Zod スキーマから型を生成します。
typescript// shared-contracts/src/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "member", "guest"]),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof UserSchema>;
export const UserCreateRequestSchema = UserSchema.omit({
id: true,
createdAt: true,
});
export type UserCreateRequest = z.infer<typeof UserCreateRequestSchema>;
ユーザーモジュールの設定
typescript// user-module/rspack.config.ts
import { defineConfig } from "@rspack/cli";
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
export default defineConfig({
entry: "./src/index.tsx",
plugins: [
new ModuleFederationPlugin({
name: "user_module",
filename: "remoteEntry.js",
exposes: {
"./UserList": "./src/components/UserList",
"./UserProfile": "./src/components/UserProfile",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
"@company/shared-contracts": { singleton: true },
},
dts: {
generateTypes: true,
typesFolder: "@mf-types",
},
}),
],
});
ホストアプリでの消費
typescript// host-app/src/App.tsx
import React, { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const UserList = React.lazy(() => import('user_module/UserList'));
const UserProfile = React.lazy(() => import('user_module/UserProfile'));
function App() {
return (
<div className="app">
<ErrorBoundary fallback={<div>モジュール読み込みエラー</div>}>
<Suspense fallback={<div>読み込み中...</div>}>
<UserList onSelect={(user) => console.log(user)} />
</Suspense>
</ErrorBoundary>
</div>
);
}
境界でのバリデーション
モジュール境界では、Contract-First で定義したスキーマを使って実行時バリデーションを行います。
typescript// user-module/src/services/UserService.ts
import { UserSchema, User } from "@company/shared-contracts";
export class UserService {
async getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// 境界で型を検証(不整合があれば即座にエラー)
return UserSchema.parse(data);
}
}
共有型管理の詳細比較
改めて、3 つの手法を詳細に比較します。
| 観点 | 共有型パッケージ | Module Federation 2.0 型生成 | Contract-First |
|---|---|---|---|
| 型安全の範囲 | コンパイル時のみ | コンパイル時のみ | コンパイル時+実行時 |
| 実装と型の同期 | 手動管理 | 自動 | スキーマから生成 |
| デプロイ独立性 | 低い | 高い | 高い |
| 初期導入コスト | 低い | 中 | 高い |
| 運用コスト | 中(バージョン管理) | 低 | 中(スキーマ管理) |
| 学習コスト | 低い | 中 | 高い |
| 向いている境界 | 内部コンポーネント間 | モジュール間 | API 境界 |
判断フローチャート
以下の図は、どの手法を選ぶかの判断フローを示しています。
mermaidflowchart TD
Start["型共有が必要"] --> Q1{"独立デプロイ<br/>が必要?"}
Q1 -->|Yes| Q2{"外部 API<br/>境界?"}
Q1 -->|No| Pkg["共有型パッケージ"]
Q2 -->|Yes| Contract["Contract-First"]
Q2 -->|No| MF["Module Federation<br/>型生成"]
この図に従い、プロジェクトの特性に応じて手法を選択してください。
実務での使い分け
検証の結果、以下のような使い分けが効果的でした。
- 同一チーム内のコンポーネント共有 → 共有型パッケージ
- チーム間のモジュール連携 → Module Federation 2.0 型生成
- 外部 API との通信 → Contract-First
一つのプロジェクト内でも、境界の性質に応じて複数の手法を併用することが多いです。
まとめ
マイクロフロントエンドにおける TypeScript の型安全は、単純に「型定義を共有すれば解決」という話ではありません。デプロイの独立性、バージョン互換、CI/CD の複雑さを総合的に考慮した設計が必要です。
本記事で解説したポイントを整理します。
- 型共有の 3 手法(共有パッケージ、Module Federation 型生成、Contract-First)は、境界の性質に応じて使い分ける
- バージョン互換は、追加は許容・削除は慎重、Union 型の拡張に注意、非推奨の段階的削除で維持する
- CI/CD 設計では、型互換チェックの自動化と変更影響範囲の検出が重要
どの手法が最適かはプロジェクトの状況によって異なります。チーム構成、リリース頻度、許容できる複雑さを踏まえて判断してください。
関連リンク
公式ドキュメント
- Module Federation - Module Federation 2.0 公式サイト
- Rspack - Rust 製の高速バンドラー
- TypeScript - TypeScript 公式ドキュメント
- Zod - TypeScript ファーストのスキーマバリデーション
設計・アーキテクチャ
- Micro Frontends - マイクロフロントエンドの基本概念
- Module Federation Examples - 実装例リポジトリ
ビルドツール
- Rspack Module Federation - Rspack での Module Federation 設定
- Rsbuild - Rspack ベースのビルドツール
著書
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
