構造的部分型の罠:なぜその「お行儀の良いオブジェクト」は型エラーにならないのか?
コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。
type UserConfig = {
name: string;
age: number;
};
function registerUser(config: UserConfig) {
// 処理…
}
// 呼び出し側
registerUser({
name: ‘Taro’,
age: 30,
role: ‘admin’, // えっ、型定義にないプロパティがなぜ通る…?
});
TypeScript初中級者が必ずと言っていいほどハマるのがこの挙動だ。`UserConfig` には存在しない `role` プロパティが渡されているにもかかわらず、TypeScriptのコンパイラは何食わぬ顔でこれをコンパイルする。
なぜか?
答えは、TypeScriptが採用している 構造的部分型(Structural Subtyping) にある。TypeScriptにおいて、オブジェクト型は「何を持っているか(What it has)」で判定され、「何であるべきか(What it is named)」という名前的型付け(Nominal Typing)ではない。「`name` と `age` さえ持っていれば、余分に何を持っていても `UserConfig` とみなす」というのがこの世界の基本原則だ。
しかし、実務の現場ではどうだろうか。APIのペイロード設計や、厳密なバリデーションが必要な設定オブジェクトにおいて、この「余剰プロパティ」のすり抜けは、タイポによるバグや、予期せぬデータ混入(セキュリティ上の脆弱性にも繋がり得る)の温床となる。
今回は、この構造的部分型の緩さを逆手に取り、コンパイルタイムで余剰プロパティを完璧に検知・排除する硬質な型設計 の極意を伝授しよう。
—
1. 変数代入と直接リテラルで「挙動が変わる」TypeScriptの深層
まず、TypeScriptのコンパイラがどのように型を評価しているかのメンタルモデルをアップデートしてほしい。
TypeScriptには、オブジェクトリテラルを直接関数に渡すときだけに発動する、「余剰プロパティチェック(Excess Property Checks)」 という特殊なメカニズムが存在する。
const rawData = {
name: ‘Taro’,
age: 30,
role: ‘admin’,
};
// これはエラーになる!
registerUser(rawData);
`rawData` という「変数」を介した途端、コンパイラはそれを `UserConfig` の構造的スーパーセット(より広い型)として解釈し、余剰プロパティのチェックをバイパスする。一方で、オブジェクトリテラルを直接渡すと、コンパイラは「未知のプロパティが含まれていないか」を厳しくスキャンする。
この「直接渡すとエラーになるのに、変数に入れるとスルーされる」という非対称性は、開発者に混乱を与える。この曖昧さを排除し、「変数経由であっても、意図しないプロパティの混入を絶対に許さない」 強制力を持つ型を作らなければならない。
—
2. 厳格な余剰プロパティ排除:`Exact` 型パターン
では、どうすればよいのか?
コンパイラに「厳密な一致」を強制する、プロダクションコードで即座に使えるテクニックを紹介しよう。
ここでは、ジェネクスと条件エンダウメント(Conditional Types)を駆使した `Exact` 型ユーティリティを実装する。
// — 核心となる型定義 —
// 1. 許可されていないプロパティを Never 型に変換するユーティリティ
type Exact
// Shapeに存在するキー以外がTに含まれていないかを検証
? Exclude
? T
: never
: never;
// 2. ラッパー関数によるガード
function createStrictUser
return config;
}
このコードがコンパイル時にどう評価されるか
1. `T` には呼び出し側が渡したオブジェクトの型が推論される。
2. `T extends Shape` でベースの構造を満たしているか確認する。
3. `Exclude
4. その差分が `never`(つまり、余分なプロパティが1つも存在しない状態)であれば `T` をそのまま返し、存在すれば `never` に落とし込むことでコンパイルエラーを引き起こす。
—
3. 実践:フロントエンド・コンポーネント設計での応用
このパターンは、ReactなどのコンポーネントPropsや、非同期APIクライアントのオプション引数で真価を発揮する。
以下のコードは、厳密な型安全性が求められる社内デザインシステムのモーコンポーネントの設計例だ。
type ModalBaseOptions = {
title: string;
isOpen: boolean;
onClose: () => void;
};
// 厳密な型制約を持つヘルパー関数(またはカスタムフックの引数)
export function useStrictModal
// 実行時処理…
return {
isOpen: options.isOpen,
title: options.title,
};
}
// ==========================================
// 呼び出し側の挙動テスト
// ==========================================
// ✅ 完璧に通る(余剰プロパティなし)
useStrictModal({
title: ‘確認’,
isOpen: true,
onClose: () => console.log(‘closed’),
});
// ❌ コンパイルエラー!
// Argument of type ‘{ title: string; isOpen: boolean; onClose: () => void; animation: string; }’ is not assignable to parameter of type ‘never’.
useStrictModal({
title: ‘警告’,
isOpen: false,
onClose: () => {},
animation: ‘fade’, // タイポや不要な設定の混入を即座にブロック
});
開発者がうっかり `animation` や `zIndex` などの独自拡張プロパティを直書きした瞬間、IDE上で即座に赤線が引かれる。コードレビューで「このプロパティ使われてませんよ」と指摘する手間が、コンパイラによって完全に自動化されるのだ。
—
4. パフォーマンスとアーキテクチャ上の注意点
チーフアーキテクトとして、このアプローチにおけるトレードオフについても言及しておかなければならない。
1. 型の評価コスト(Type Instantiation Depth)
複雑な条件分岐型(`Exclude` やジェネクスの連鎖)を多用すると、TypeScriptの型チェッカー(TSServer)のメモリ消費量が増大し、大規模なモノレポ環境では IDE のサジェストが重くなる原因(ホバー時の表示遅延など)になる。
すべての関数に `Exact` を適用するのではなく、「外部からの入力境界(APIクライアント、公開UIコンポーネントのProps、プラグイン設定)」に絞って適用するのが正しいアーキテクチャ判断だ。
2. IDEのエラーメッセージの読みやすさ
`Exact` 型が発動してエラーになった際、VSCodeなどのエディタには以下のようなメッセージが表示される。
> `Argument of type ‘…’ is not assignable to parameter of type ‘never’.`
初学者には「なぜ `never` なのか?」が直感的に伝わりにくい。そのため、チームの開発メンバー向けに、この設計を採用する理由とエラーの意味をチームのドキュメント(ADRなど)に残しておくことが、テクニカルリードとしての重要な責務となる。
—
5. まとめ
TypeScriptの構造的部分型は強力な武器であるが、時として「緩すぎる型安全」を生む。
「動くからいいや」ではなく、「意図しないデータをコンパイル段階で物理的に排除する」という強い意志を持って型を設計すること。それこそが、プロダクトの寿命を延ばし、リファクタリングに強いコードベースを築く唯一の道である。
あなたの書くコードの型は、意図しない入札を拒む、堅牢な城壁となっているだろうか?
今日のレビューから、ぜひこの `Exact` パターンを取り入れてみてほしい。