なぜそのコードはバグるのか?TypeScriptの「Widening(型広げ)」を完全掌握し、堅牢なフロントエンドを構築する
コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。
const config = {
theme: ‘dark’,
retries: 3,
};
// 意図:’dark’型を受け入れたいのに、string型になってしまいエラーになる
function applyTheme(theme: ‘light’ | ‘dark’) {
/ … /
}
applyTheme(config.theme); // ❌ 怒られる:Argument of type ‘string’ is not assignable to parameter of type ‘”light” | ‘dark'”.
「あれ?`config.theme`は定数(`const`)で定義したのだから、中身は常に`’dark’`のはずでは?」と思ったなら、TypeScriptの型システムにおけるWidening(型広げ)の挙動を見落としている。
今回は、TypeScriptコアの挙動からコンパイル時の評価メカニズムまで踏み込み、意図しない型広げを防ぐための二大アプローチ(`as const` と明示的な型注釈)を、実務の現場ですぐ使える設計パターンとともに叩き込む。
—
1. なぜ型は広がるのか?型推論の裏側
TypeScriptのコンパイラ(`tsc`)は、変数を初期化する際、「この変数は後から再代入されるかもしれない」という将来の変更可能性を考慮して型を推論する。
let mode = ‘dark’; // 推論:string
const status = ‘active’; // 推論:’active’ (再代入不可のため、リテラル型が維持される)
しかし、`const` で宣言したオブジェクトや配列のプロパティは話が別だ。
const appConfig = {
endpoint: ‘https://api.example.com’,
timeout: 5000,
};
この時、TypeScriptは `appConfig.endpoint` を `string` 型へ、`appConfig.timeout` を `number` 型へとWidening(拡張)する。オブジェクトのプロパティは後から書き換え可能(mutability)であるため、リテラル型を維持するよりも、一般的なプリミティブ型に一般化した方が安全であるとコンパイラが判断するからだ。
この「親切心」が、厳密な型安全性を求めるフロントエンド開発においてバグの温床となる。
—
2. 抑制の二大アプローチ:`as const` vs 明示的型注釈
このWideningを制御する手段として、我々には主に2つのアプローチが用意されている。それぞれの特性とコンパイル時の振る舞いを理解し、適材適所で使い分ける必要がある。
アプローチA: `as const`(Const Assertion)
オブジェクトや配列、プリミティブの末尾に付与することで、コンパイラに対して「この構造を完全にイミュータブル(読み取り専用)として扱い、可能な限り狭いリテラル型で推論せよ」と命令する。
const routes = {
home: ‘/’,
settings: ‘/settings’,
} as const;
// 評価される型:
// {
// readonly home: “/”;
// readonly settings: “/settings”;
// }
- メリット: 記述が簡潔。ネストしたオブジェクトや配列の要素まで全て `readonly` かつリテラル型になる。
- ユースケース: ルーティング定義、ステートマシーンの設定値、アクションの型定義など、変更されないことが絶対の「データ定数」に最適。
アプローチB: 明示的な型注釈(Type Annotation)
変数やプロパティに対して、開発者が意図する型を直接バインドする。
type AppConfig = {
endpoint: string;
timeout: number;
};
const appConfig: AppConfig = {
endpoint: ‘https://api.example.com’,
timeout: 5000,
};
- メリット: 変数代入の時点で型が強制されるため、期待する型からの逸脱をコンパイルエラーとして即座に検知できる。
- ユースケース: 後から一部のプロパティを書き換える必要がある設定オブジェクトや、外部から受け取った初期値を保持するステートなど。
—
3. 【実践】プロダクションコードで学ぶ設計パターン
ここからは、実務の現場(API連携、UIコンポーネント設計)で頻出する、Widening起因のバグを防ぐ洗練されたコードパターンを見ていこう。
パターン1: APIステータスとアクションマッピングの厳密な型安全化
UIコンポーネントでよくある、ステータスに応じた処理分岐の設計だ。
// 悪い例:Wideningにより型が string になり、switch文やマッピングで網羅性(Exhaustiveness)が失われる
const BAD_STATUS_MAP = {
IDLE: ‘idle’,
LOADING: ‘loading’,
SUCCESS: ‘success’,
ERROR: ‘error’,
};
// type: { IDLE: string; LOADING: string; … } ❌
// 良い例:as const を用いてリテラル型と readonly を強制する
export const STATUS = {
IDLE: ‘idle’,
LOADING: ‘loading’,
SUCCESS: ‘success’,
ERROR: ‘error’,
} as const;
// typeof とkeyofを組み合わせることで、値から堅牢なユニオン型を導出する
export type AppStatus = typeof STATUS[keyof typeof STATUS];
// 評価される型: “idle” | “loading” | “success” | “error”
function handleStatusChange(status: AppStatus) {
switch (status) {
case STATUS.SUCCESS:
// 処理
break;
case STATUS.ERROR:
// 処理
break;
default:
// 網羅性チェック(Never型を活用した高度なパターンの土台)
const _exhaustiveCheck: never = status;
return _exhaustiveCheck;
}
}
パターン2: フォームのバリデーションルールと型推論
動的なフォーム定義において、各フィールドの型を正確に推論させつつ、Wideningを抑制する実践的なコンポーネント設計。
type FieldConfig
defaultValue: T;
validate: (value: T) => boolean;
};
// ヘルパー関数を挟むことで、ジェネリクスを通じた正確な型推論とWidening抑制を両立させる
function createField
return config;
}
const formConfig = {
username: createField({
defaultValue: ”, // string として広がるのを防ぎたい場合、あるいは厳密に制御したい場合
validate: (val) => val.length > 0,
}),
age: createField({
defaultValue: 0,
validate: (val) => val >= 18,
}),
};
さらに、設定オブジェクト全体を `as const` で固めるアプローチもある。
const THEME_OPTIONS = [‘light’, ‘dark’, ‘system’] as const;
type ThemeOption = typeof THEME_OPTIONS[number];
// 評価される型: “light” | “dark” | “system”
interface UserPreferences {
theme: ThemeOption;
notificationsEnabled: boolean;
}
const defaultPreferences: UserPreferences = {
theme: ‘dark’, // ‘dark’ は ThemeOption に完全に合致する
notificationsEnabled: true,
};
—
4. パフォーマンスとコンパイル速度への影響
チーフアーキテクトとして、パフォーマンスの観点についても言及しておこう。
`as const` を乱用すると、巨大なJSONオブジェクトやサードパーティのモックデータ全体に深い `readonly` とリテラル型が付与される。これにより、TypeScriptの型チェッカー(TSServer)が保持すべき型のメモリフットプリントが増大し、IDE(VSCodeなど)での型補完の遅延(LSPのラグ)を引き起こす原因となる。
💡 アーキテクトからの最適化アドバイス
1. 境界線で絞る: アプリケーションの内部ロジックやドメインモデルの境界(APIレスポンスのスキーマ定義、UIの定数定義など)でのみ `as const` を使用する。
2. 巨大なデータには型注釈を: 1000行を超えるようなマスターデータや設定ファイルには、`as const` ではなく、明示的なインターフェース型注釈(Type Annotation)を適用してコンパイル負荷を軽減する。
—
まとめ
TypeScriptにおけるWideningの挙動を理解することは、単なるエラー回避にとどまらず、「実行時エラーをコンパイル時で完全に根絶する」ための最高峰の武器となる。
- 変数の型が意図せず広くなっていると感じたら、まずはコンパイラがどう推論しているか(`string` になっていないか)を疑え。
- 変更不可の定数群には `as const` + `typeof keyof` イディオムを適用し、型安全なユニオンを導出せよ。
- メンテナンス性とコンパイルパフォーマンスのバランスを見極め、明示的型注釈と適切に使い分けろ。
この知見をあなたのチームのコードレビューに取り入れ、堅牢で美しいプロダクションコードベースを築き上げてほしい。