大規模プロジェクトにおけるTypeScriptベストプラクティス
大規模TypeScript開発のベストプラクティス: strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes、生成型、実行時検証、CIガードレール.
規模が大きくなるほど、TypeScriptの恩恵を受けるには一貫した規律が求められます。具体的には、strictモードをベースラインとし、境界を明示的かつ検証済みの形で定義し、型をエッジで生成することでチーム間の乖離を防ぎ、チーム全体が実際に合意した少数のパターンを徹底することです。言語そのものが難しい時代はとっくに終わっています。難しいのは、100万行規模・複数コントリビューターのコードベースを、PRのたびに型安全性のリグレッションが忍び込まないようにリファクタリング可能な状態に保つことです。本ガイドでは、そのような規模に耐えうる規約・コンパイラフラグ・アーキテクチャパターンを解説します。また、おそらく引き継いだであろう緩いコードベースからの移行パスも示します。内容は2026年の状況をリセットした2つのリリース、TypeScript 6.0(GA)および7.0(Release Candidate)に対応しています。
重要なポイント
- TypeScript 6.0(2026年3月23日リリース)以降、
strictはコンパイラレベルでデフォルトtrueとなりました。そのため、モダンな大規模コードベースにおいてstrictモードはスタートラインであり、ゴールではありません。 - 大規模コードベースで実際に効果を発揮する2つのフラグは
strictに含まれていません。noUncheckedIndexedAccessとexactOptionalPropertyTypesは明示的に有効化する必要があり、strictが見逃す配列インデックスおよびオプショナルプロパティのバグを検出します。 - マルチチームのコードベースにおいて最も高いレバレッジを持つプラクティスは型の生成です。フロントエンドとバックエンドの両方が1つのOpenAPIまたはPrismaスキーマから型を派生させれば、両者が物理的に乖離することはなく、コントラクトが変更された瞬間にCIが失敗します。
- 静的型はコンパイル時の約束であり、ランタイムのチェックではありません。
Userとして型付けされたレスポンスはそうであるとアサートされているに過ぎないため、すべての外部境界において生成された型に加えてランタイムバリデーションが必要です。 - Microsoftによると、TypeScript 7.0のGoベースのコンパイラは大規模コードベースにおいて6.0より約10倍高速であり、2026年6月のRelease Candidate時点で標準の
tscバイナリおよびtypescriptパッケージに同梱されています。
コンパイラの規律:strictモードは床であり、到達点ではない
薄いベストプラクティス記事の多くは、いまだに「strictモードを有効にしよう」と、まるで勇気ある選択であるかのように伝えています。しかしそのフレーミングはもはや時代遅れです。TypeScript 6.0のリリースノートによると、strictはコンパイラレベルでデフォルトtrueとなっています。以前のデフォルトfalseに依存していた場合は、明示的に"strict": falseと設定する必要があります。TypeScript 6.0は2026年3月23日に発表され、現行のJavaScriptコードベースに基づく最後のリリースとなる予定です。つまり、コンパイラを更新したプロジェクトでは、strictは前提のベースラインとなります。
大規模プロジェクトにとっての真のアップグレードは、strictにバンドルされていない2つの高価値フラグです。strictはおよそ9つの型安全チェック(noImplicitAny、strictNullChecksなど)を有効化しますが、noUncheckedIndexedAccessとexactOptionalPropertyTypesは含まれていません。前者はすべての未宣言インデックスアクセスにundefinedを追加し、後者はundefinedに設定されたプロパティと存在しないプロパティを区別します。これらは「strict」なコードベースでも見逃されがちなバグを検出します。要素の存在を前提とした配列ルックアップや、存在するがundefinedなオプショナルフィールドです。
バージョンスタンプ付きの大規模プロジェクト向けtsconfig.json(TypeScript 6.0.x):
{
"compilerOptions": {
"strict": true, // 6.0以降デフォルト; 古いツールチェーンのために明示的に記述
"noUncheckedIndexedAccess": true, // arr[i] は T ではなく T | undefined
"exactOptionalPropertyTypes": true, // { x?: number } は { x: undefined } を拒否
"verbatimModuleSyntax": true, // type-onlyインポートを強制(ビルドパフォーマンス参照)
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"composite": true // プロジェクト参照に必須
}
}
段階的なstrictness移行のプレイブック
Discover how at OpenReplay.com.
緩いコードベースを引き継いだ場合、すべてのフラグを一度に切り替えないでください。ディレクトリ単位でstrictnessを適用し、CIでtype-coverageのパーセンテージを追跡し、カバレッジが後退しないようにしきい値を引き上げていきましょう。暗黙のanyが数千箇所ある20万行のアプリは、初日にはクリーンにコンパイルできません。2,800件ものエラーを含むPRはレビュー不可能です。
現実的な手順:
- グローバルに
strict: trueを設定しつつ、適用範囲をスコープします。緩いベースのtsconfigを維持しつつ、プロジェクト参照を使って機能ディレクトリごとに厳格なtsconfig.jsonを追加し、最も問題のある箇所から順に締めていきます。 type-coverageをCIにラチェットとして追加します。型付きシンボルのパーセンテージが最後に記録した数値を下回った場合にビルドを失敗させます。カバレッジは横ばいになることはあっても、後退は許しません。- 追加フラグ(
noUncheckedIndexedAccess、exactOptionalPropertyTypes)を残りの違反箇所に// @ts-expect-errorを付けてステージングし、リストを消化していきます。@ts-expect-errorは抑制が不要になると自己報告するため、バックログがサイレントに腐敗することはありません。
大規模における型設計
大規模における良い型設計は、不正な状態をコンパイル不可にし、ドメインの誤りを明確にします。3つのパターンがほとんどの仕事をこなします。残りは一貫性です。
interfaceとtypeのルール、一度だけ述べます: 公開・拡張可能なオブジェクトコントラクトにはinterfaceを使用します(宣言マージをサポートし、大きなオブジェクト形状に対してより良いエラーメッセージを生成する傾向があります)。ユニオン型、インターセクション型、マップ型、条件型にはtypeを使用します。議論はそれだけです。ルールを決め、lintで強制し、先に進みましょう。
不可能な状態を表現不可能にする
ブール値フラグの乱用は、大規模UIコードベースにおける「これは起こり得ない」バグの最も一般的な原因です。以下の型は16通りの組み合わせを許容しており、そのほとんどは意味をなしません。isLoadingとerrorが同時に設定されたり、エラー中にdataが存在したりします:
// アンチパターン: 各フィールドが独立しており、不可能な状態が許容される
interface RequestState<T> {
isLoading: boolean;
isError: boolean;
data?: T;
error?: Error;
}
判別共用体(discriminated union)を使えば、実際に発生しうる状態だけに絞り込まれ、コンパイラが各ケースの処理を強制します:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render<T>(state: RequestState<T>) {
switch (state.status) {
case "success":
return state.data; // dataはここにのみ存在する
case "error":
return state.error; // errorはここにのみ存在する
// ケースの漏れは適切な網羅性チェックによりコンパイルエラーになる
}
}
ブール値フラグによるリクエスト状態のセッションリプレイは、このパターンが排除する障害モードを頻繁に明らかにします。2つの独立したブール値が同期を失い、スピナーと古いデータが同時にレンダリングされるUIです。
ドメインIDをブランド化する
ブランド型は、UserIdとOrderIdをランタイムでは両方stringであっても互換性のない型にし、一方が期待される場所に他方を渡すことをコンパイルエラーにします:
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const asUserId = (s: string) => s as UserId;
function cancelOrder(id: OrderId) { /* ... */ }
const u = asUserId("u_123");
cancelOrder(u); // ❌ 'UserId'型の引数を'OrderId'型のパラメータに割り当てることはできません
5つの文字列引数を持つ関数シグネチャにおいて、ブランド化は引数の順序間違いによるバグをコンパイル時に発見するか、本番環境で発見するかの違いを生みます。
anyよりunknownを優先してください。 anyはチェッカーを無効化してサイレントに伝播しますが、unknownは使用前にナローイングのステップを強制します。lintでanyを禁止し、JSON.parse、catchバインディング、型のないライブラリの戻り値など、すべての外部値を証明されるまでunknownとして扱いましょう。リテラルの設定やルックアップテーブルにはas constを使用し、拡張されたプリミティブ型ではなくナローなリテラル型として推論されるようにしましょう。
境界で型をモデリングし生成する
大規模コードベースにおいて最もレバレッジの高いアーキテクチャ上の決断は、エッジをどのように型付けするかです。2つのルールがあります。
まず、ワイヤー、データベース、UIで1つのUser型を再利用しないでください。APIレスポンス、DTO、ドメインエンティティを3つの異なる型としてモデリングし、ある境界での変更が別の境界をサイレントに破壊しないようにします。バックエンドがシリアライズする形状、ORMが返す形状、コンポーネントが消費する形状は時間とともに乖離します。それらを1つの型に統合することは、すべてのレイヤーを他のすべてのレイヤーに結合させることになります。
次に、境界の型は手書きせず生成してください。フロントエンドとバックエンドの両方が1つのスキーマから型を派生させれば、両者が物理的に乖離することはなく、コントラクトが変更された瞬間にCIが失敗します。openapi-typescriptを使ってOpenAPI 3.0/3.1ドキュメントをランタイムフリーの型に変換し、Prismaでデータベース由来の型を、GraphQL Code Generatorで型付きオペレーションを生成しましょう。CIで再生成し、差分があれば失敗させます:
# CIステップ: 再生成してコミット済みの型が古い場合は失敗
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts
しかし、生成された型はあくまでコンパイル時の約束に過ぎません。静的型はコンパイル時の約束であり、ランタイムのチェックではありません。Userとして型付けされたレスポンスはそうであるとアサートされているに過ぎないため、すべての外部境界において生成された型に加えてランタイムバリデーションが必要です。ZodやValibotのようなスキーマライブラリで実際のペイロードを検証し、スキーマから静的型を派生させることで、1つの定義が両方のレイヤーを守るようにしましょう:
import { z } from "zod";
const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;
const res = await fetch("/api/me");
const user = User.parse(await res.json()); // 実際の形状が異なる場合はthrow
境界を型付けするだけでなく検証する必要性は経験的なものです。静的型はランタイムには消え去ります。セッションリプレイは、型では防げない障害を目で見るための手法の1つです。実際のAPIレスポンスが宣言された形状と一致せず、ユーザーのセッションでUIが壊れる瞬間がそれです。
マルチコントリビューターのコードベースにおける型の整理
- 型は使用するコードと同じ場所に配置します。同じファイル、または隣接する
*.types.tsファイルです。types/index.ts(または専用パッケージ)は真に共有されるコントラクトのみに限定します。 - 技術的なレイヤーではなく機能/ドメインのフォルダで整理し、機能の型・コンポーネント・ロジックが一緒に置かれ、オーナーシップが明確になるようにします。
- モノレポではプロジェクト参照と
pathsマッピングでパッケージを接続し、脆弱な../../../チェーンではなくクリーンなモジュール境界(@org/billing)を通じてインポートが行われるようにし、コンパイラが依存関係グラフを強制できるようにします。
2026年のビルドパフォーマンス:ネイティブコンパイラが状況を変える
ビルドパフォーマンスの議論は根本的に変わりました。もはやtscフラグで数秒を削る話ではありません。TypeScriptは2026年6月18日に7.0 Release Candidateを発表しました。ネイティブコードの速度と共有メモリ並列処理により、TypeScript 6.0より約10倍高速です。Microsoftのヘッドラインベンチマークでは、約150万行のVS Codeコードベースを旧コンパイラの77.8秒に対して約7.5秒でチェックしていますが、小規模プロジェクトでは倍率は小さくなります。
パッケージングこそが正確に把握すべき点です。RCにおける主な実用的変更はパッケージングです。GoベースのリライトはネイティブプレビューパッケージからRegularのTypeScript npmパッケージに移行したため、TypeScript 7.0は通常のtscコンパイラとして広範なテストに対応しました。npm install -D typescript@rcでインストールし、標準のtscバイナリを実行します。旧来のtsgo/@typescript/native-previewパッケージはナイトリービルドのみを提供するようになりました。2026年6月末時点で、最新の安定版はTypeScript 6.0.3であり、7.0はRCで、安定版GAはRCから約1ヶ月以内に予定されています。バージョンとステージは変動しやすいため、採用時には必ず最新情報を確認してください。
自身の設定で制御できる構造的なレバーは、どのコンパイラでも依然として価値があります。インクリメンタルかつ依存関係を考慮したビルドのためのプロジェクト参照と、verbatimModuleSyntaxによって強制されるtype-onlyインポートです。後者により、型のみのシンボルが消去され、ランタイムインポートとして出力されることがなくなります。verbatimModuleSyntax(5.0で導入)は現在推奨されるアプローチです。それが置き換えたimportsNotUsedAsValuesとpreserveValueImportsは5.5でno-opとなり、6.0以降は指定するとエラーになります。
import type { User } from "./user"; // JS出力から完全に消去される
import { fetchUser } from "./api"; // 値のインポート、保持される
ガードレールを自動化する
強制されない規約は劣化します。typescript-eslintを型認識ルール(no-explicit-any、no-floating-promises、no-misused-promises)とともに実行し、上記のパターンがレビューではなく機械的にチェックされるようにしましょう。type-onlyインポートの強制はconsistent-type-importsのlintルールではなくverbatimModuleSyntaxに任せてください。両方を実行することは冗長であり、競合するエラーを生む可能性があります。すべてのPRのCIでtsc --noEmitをハードゲートとして実行し、移行プレイブックのtype-coverageラチェットと組み合わせましょう。そして、すべての自動化の上に1つの人間的なガードレールを置いてください。それは巧妙さより明快さです。シニアエンジニアが読み解くのに10分かかるような深くネストされた条件型・マップ型は、技術力の誇示ではなく負債です。大規模プロジェクトの型コードのほとんどは、退屈で読みやすく、明白であるべきです。
一貫性こそが核心であり、洗練さではありません。strictが省略している2つのフラグを有効にし、不正な状態をコンパイル不可にし、境界の型を生成・検証し、残りはCIに強制させましょう。今週、上記のバージョン固定されたtsconfigをスタートベースラインとして採用し、type-coverageラチェットを最も問題のあるディレクトリに向けて、改善を始めましょう。
よくある質問
strictモードは大規模TypeScriptプロジェクトに十分ですか?
いいえ。TypeScript 6.0以降、strictはすでにコンパイラレベルでデフォルトtrueとなっているため、達成目標ではなくベースラインです。大規模コードベースで最も重要な2つのフラグはstrictファミリーには含まれていません。未宣言のインデックスアクセスにundefinedを追加するnoUncheckedIndexedAccessと、undefinedに設定されたプロパティと存在しないプロパティを区別するexactOptionalPropertyTypesです。両方を明示的に有効にした上で、境界の型とランタイムバリデーションを追加してください。
TypeScriptのinterfaceとtypeの違いは何ですか?それぞれいつ使うべきですか?
公開・拡張可能なオブジェクトコントラクトにはinterfaceを使用してください。宣言マージをサポートし、大きなオブジェクト形状に対してより明確なエラーメッセージを生成する傾向があります。interfaceでは表現できないユニオン型、インターセクション型、マップ型、条件型にはtypeを使用してください。大規模チームにとっての実用的なルールは、この規約を一度決め、lintルールで強制し、議論をやめることです。プレーンなオブジェクト形状に対しては両者とも同一の型チェックにコンパイルされるため、選択は能力ではなく表現力と一貫性の問題です。
OpenAPIやPrismaから生成された型があれば、ランタイムバリデーションは不要ですか?
いいえ。生成された型はコンパイル時の約束に過ぎません。Userとして型付けされたJSONレスポンスは、その形状に一致すると単にアサートされているだけです。コンパイラはランタイムに実際のペイロードを検査しないため、バックエンドの変更やnullフィールドは依然として通り抜けます。生成された型はフロントエンドとバックエンドのコントラクト上の乖離を防ぎますが、すべての外部境界で実際のペイロードを検証するためにZodやValibotのようなスキーマライブラリが依然として必要です。1つの定義が両方のレイヤーを守るよう、スキーマから静的型を派生させてください。
TypeScript 7.0をRelease Candidate段階でインストールして実行するにはどうすればよいですか?
npm install -D typescript@rcでインストールし、標準のtscバイナリを実行してください。2026年6月のRelease Candidate時点で、Goベースのネイティブコンパイラはネイティブプレビューパッケージから通常のtypescript npmパッケージに移行したため、RC用の独立したtsgoバイナリはもう存在しません。旧来のtsgoおよびtypescript native-previewパッケージはナイトリービルドのみを提供するようになりました。Microsoftによると、7.0は大規模コードベースにおいて6.0より約10倍高速です。バージョンとステージは変動しやすいため、採用前に必ず最新情報を確認してください。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k