1. ホーム
  2. TypeScript

【TypeScript】過剰プロパティチェック(Excess Property Check)とは|オブジェクトリテラルの余分なプロパティがエラーになる理由

Share

型どおりのプロパティを書いたつもりなのに、Object literal may only specify known properties というエラーが出て戸惑ったことはないでしょうか。しかも、同じオブジェクトをいったん変数に入れてから渡すと、なぜかエラーが消えます。これは TypeScript の過剰プロパティチェック(Excess Property Check)という仕組みによるもので、バグではなく意図された動作です。この記事では、なぜこのチェックが存在するのか、どんな場面で働いてどんな場面で働かないのか、そして解消する方法とそれぞれの代償を、実際に tsc で確認したエラーメッセージとあわせて解説します。

オブジェクトリテラルを直接書いたときだけ出るエラー

まずは典型的なエラーを見てみます。ButtonProps という型に対して、color と書くべきところを colour と綴ってしまったケースです。

button.ts
interface ButtonProps {
  label: string;
  color?: string;
}

function renderButton(props: ButtonProps): string {
  return props.label;
}

// error TS2561: Object literal may only specify known properties,
// but 'colour' does not exist in type 'ButtonProps'.
// Did you mean to write 'color'?
renderButton({ label: '送信', colour: 'red' });

エラー番号は2種類あります。上の例のように既存のプロパティ名とよく似た綴りが見つかったときTS2561 になり、Did you mean to write 'color'? という提案が付きます。似た名前が見つからなければ TS2353 で、こちらは and 'xxx' does not exist in type 'Y'. という文言になります。

user.ts
type User = { id: number; name: string };

// error TS2353: Object literal may only specify known properties,
// and 'age' does not exist in type 'User'.
const user: User = { id: 1, name: 'taro', age: 30 };

どちらも意味は同じで、「オブジェクトリテラルには、その型が知っているプロパティしか書けません」と言っています。関数呼び出しの引数で出た場合は、これらのエラーが TS2345(引数の型が合わない)の内側に入れ子で表示されることもあります。エディタの赤波線にマウスを乗せて全文を読むと、どのプロパティが余分なのかがはっきり分かります。

構造的部分型なのに、なぜ余分なプロパティが弾かれるのか

ここで疑問が出てきます。TypeScript の型システムは構造的部分型(structural typing)を採用しています。「その型が要求するプロパティをすべて持っていれば、余計なものが付いていても代入してよい」というのが基本ルールのはずです。実際、次のコードはまったくエラーになりません。

structural.ts
type User = { id: number; name: string };

const draft = { id: 2, name: 'hanako', age: 30 };

// エラーにならない(id と name を持っているので User として通用する)
const user2: User = draft;

同じ { id, name, age } という形なのに、直接書けばエラー、変数を経由すれば通る。この差はまさに TypeScript が意図的に作ったものです。過剰プロパティチェックは、構造的部分型のルールに乗せた追加の安全網であって、型の互換性判定そのものではありません。

安全網が必要な理由は、冒頭の colour の例が示しています。color は省略可能なプロパティなので、構造的部分型のルールだけで判断すると { label: '送信', colour: 'red' }ButtonProps として完全に正しい値です。colour はただの余計なプロパティ、color は省略されただけ。型としては何も問題がありません。しかし書いた人の意図は明らかに「色を赤にする」であり、実行してみると色が付かずに悩むことになります。オブジェクトリテラルをその場で書いているということは、その型のために書いているはず——この前提に立てば、型が知らないプロパティは打ち間違いか型定義の漏れのどちらかです。だからその瞬間だけ厳しくチェックする、というのが過剰プロパティチェックの設計思想です。

変数に入れると消えるのは「freshness」が失われるから

TypeScript の内部では、その場で書かれたばかりのオブジェクトリテラルの型に fresh(新鮮)という印が付いています。過剰プロパティチェックが働くのは、この印が付いた型を別の型に代入しようとしたときだけです。

そしてこの印は、リテラルをいったん変数に代入した時点で消えます(型の widening と同時に freshness が失われる、という言い方をします)。上の例で draft の型は { id: number; name: string; age: number } と推論されますが、これはもう「今書かれたリテラル」ではなく普通の型なので、User への代入では構造的部分型の通常ルールだけが適用されます。エラーが消えるのは仕様の抜け穴ではなく、「一度変数に入れたということは、この値には他の用途もあるのだろう」という判断が働いた結果です。

チェックが働く場所と働かない場所

「リテラルを直接書いたとき」という条件は、変数への代入だけを指すわけではありません。関数の引数、return 文、配列リテラルの中身、ネストしたオブジェクト、アロー関数の戻り値など、リテラルが期待される型と出会うあらゆる場所で働きます。次のコードはすべて age に対して TS2353 になります。

where-it-checks.ts
type User = { id: number; name: string };

// 配列リテラルの要素
const users: User[] = [{ id: 1, name: 'a', age: 1 }];

// 関数の戻り値
function findUser(): User {
  return { id: 1, name: 'a', age: 1 };
}

// ネストしたオブジェクト(内側のリテラルもチェックされる)
interface Page {
  author: User;
}
const page: Page = { author: { id: 1, name: 'a', age: 1 } };

// コールバックの戻り値(戻り値の型を書いた場合)
const list = [1].map((n): User => ({ id: n, name: 'a', age: 1 }));

ネストの例は特に覚えておく価値があります。{ author: { ... } } のように入れ子で書いた場合、外側だけでなく内側のリテラルにもそれぞれ独立して印が付くため、どの階層でもチェックが効きます。逆に const innerRaw = { id: 1, name: 'a', age: 1 }; と切り出して { author: innerRaw } と書けば、内側は変数経由になるのでエラーは出ません。

もうひとつ注意したいのが型アサーションとの関係です。as const は freshness を消してくれません。読み取り専用にする効果はありますが、余分なプロパティはそのままエラーになります。チェックを止められるのは as ターゲットの型 という形の型アサーションだけです。

ユニオン型では「どのメンバーにも無いか」が基準になる

代入先がユニオン型のときは判定が少し変わります。単純に言えば、ユニオンを構成するどのメンバーにも存在しないプロパティだけがエラーになります。次の例では strokeCircle にはありませんが Square にはあるため、エラーになりません。

union.ts
type Circle = { radius: number; fill?: string };
type Square = { size: number; stroke?: string };

// エラーにならない(stroke は Square に存在するプロパティ)
const shape: Circle | Square = { radius: 10, stroke: 'black' };

// error TS2353: Object literal may only specify known properties,
// and 'opacity' does not exist in type 'Circle | Square'.
const shape2: Circle | Square = { radius: 10, opacity: 1 };

これは緩すぎるように見えますが、ユニオンのどのメンバーとして扱われるかがまだ確定していない段階では、こう判定するしかありません。エラーメッセージの型名が 'Circle | Square' とユニオンのまま表示されている点にも注目してください。

ところが、判別可能なユニオン(discriminated union)の場合は挙動が変わります。リテラルの type: 'text' のような判別子から代入先が1つのメンバーに絞り込まれるため、そのメンバー基準で厳密にチェックされます。

discriminated-union.ts
type TextField = { type: 'text'; maxLength?: number };
type SelectField = { type: 'select'; options: string[] };

// error TS2353: Object literal may only specify known properties,
// and 'options' does not exist in type 'TextField'.
// (type: 'text' によって TextField に絞り込まれている)
const f1: TextField | SelectField = { type: 'text', options: [] };

optionsSelectField に存在するプロパティですが、判別子で TextField に絞られたのでエラーになります。しかもエラーメッセージの型名が 'TextField' になっているので、絞り込みが起きたことがすぐ分かります。フォーム定義やイベントの型など、判別可能なユニオンを使っている場面ではこちらの厳しい判定が効くと考えてください。設計としても、判別子を持たせておいたほうが打ち間違いを拾えるという利点があります。

スプレッド構文だと余分なプロパティが素通りする

デフォルト値をスプレッドで展開してから上書きする、という書き方はよく使われますが、ここには落とし穴があります。スプレッドで展開された部分は過剰プロパティチェックの対象になりません。

spread.ts
interface ButtonProps {
  label: string;
  color?: string;
}

const defaults = { label: '送信', colour: 'red' };   // colour は打ち間違い

// エラーにならない(colour がそのまま紛れ込む)
const p1: ButtonProps = { ...defaults };

// error TS2353: Object literal may only specify known properties,
// and 'size' does not exist in type 'ButtonProps'.
// (明示的に書いた size だけがチェックされる)
const p2: ButtonProps = { ...defaults, size: 'lg' };

スプレッドの中身は「別の場所で作られた値」なので、変数を経由したのと同じ扱いになります。一方、同じリテラルの中に自分の手で書いたプロパティにはちゃんと印が付いているので、そこだけはチェックされます。つまり { ...defaults, size: 'lg' } では size は弾かれるが colour は通る、という混在した状態になるわけです。

React のコンポーネントに {...props} を渡すときや、設定オブジェクトをマージするときは、この抜け道を意識しておく必要があります。展開する側の値に型注釈を付けておく(const defaults: ButtonProps = { ... } と書く)だけで、defaults を定義した場所でチェックが働くようになるので、そこで打ち間違いを潰しておくのが確実です。

エラーを消す5つの方法と、それぞれが失うもの

実際にエラーが出たとき、取れる手段は大きく5つあります。どれも「エラーが消える」という点では同じですが、失われる安全性がまったく違うので、先に全体像を並べておきます。

方法利点欠点
型定義を直す根本解決。他の型チェックもすべて働き続ける自分で変更できる型に限られる
インデックスシグネチャを足す任意のプロパティを正式に許可できるその型全体で打ち間違いを検出できなくなる
変数に入れてから渡す書き換えが少なく、必須プロパティの検査は残る余分なプロパティが完全に素通りする
型アサーション asその1箇所だけを局所的に黙らせられるプロパティの型不一致まで一緒に隠れることがある
satisfies を使う推論結果を保ったまま型を検証できる過剰プロパティチェックは働くのでエラーは消えない

まずは型定義が正しいかを疑う

最初にやるべきことは、エラーを消す方法を探すことではなく、そのプロパティが本当に必要なのかを確かめることです。過剰プロパティチェックが指摘するケースの大半は、綴りの間違い(colour / coloronchange / onChangeclass / className)か、型定義の更新漏れです。API のレスポンスに新しいフィールドが増えたのに型を直していない、という状況なら、答えは型に age: number を足すことです。チェックはまさにそれを教えてくれています。

変数に入れる方法は「今後ずっと素通り」を意味する

いちばん手軽なのは、リテラルを一度 const に入れてから渡す方法です。ただしこれは freshness を捨てているだけなので、その先で何を書いても余分なプロパティは検出されません。安全性がまったく残らないわけではなく、必須プロパティの不足やプロパティの型不一致は引き続き検出されます。

via-variable.ts
interface ButtonProps { label: string; color?: string; }

const raw = { label: 'a', colour: 'red' };
const b1: ButtonProps = raw;    // 打ち間違いは素通りする

const raw2 = { label: 123 };
// error TS2322: Type '{ label: number; }' is not assignable to type 'ButtonProps'.
//   Types of property 'label' are incompatible.
//     Type 'number' is not assignable to type 'string'.
const b2: ButtonProps = raw2;   // 型の不一致はちゃんと検出される

意図的に余分なデータを持ち回りたい(ローカルで使う一時的なフラグを付けたまま関数に渡したい、など)ときには妥当な選択です。逆に、単にエラーを消したいだけで変数に切り出しているなら、それは打ち間違いを見逃す入り口を作っているだけなので考え直したほうがよいでしょう。

as は型が近すぎるときしか効かない

型アサーションを付ければチェックは止まります。ただし as は「型チェックを全部無効にする呪文」ではなく、元の型と対象の型が十分に重なっているときだけ許される操作です。プロパティの型そのものが食い違っていると、別のエラーに切り替わります。

assertion.ts
// エラーにならない(余分なプロパティは黙る)
const p3: ButtonProps = { label: 'a', colour: 'red' } as ButtonProps;

// error TS2352: Conversion of type '{ label: number; color: string; }' to type
// 'ButtonProps' may be a mistake because neither type sufficiently overlaps with
// the other. If this was intentional, convert the expression to 'unknown' first.
//   Types of property 'label' are incompatible.
//     Type 'number' is not comparable to type 'string'.
const p7 = { label: 123, color: 'red' } as ButtonProps;

怖いのは、label が正しく string でありさえすれば as が通ってしまう点です。as を書いた瞬間、そのリテラルに関して「余分なプロパティがあるかどうか」をコンパイラに問いかける機会は失われます。あとから型定義が変わっても、この行だけは何も教えてくれません。テストコードで一部のプロパティだけ埋めたモックを作るような、影響範囲が閉じた場面に限って使うのが安全です。

satisfies では過剰プロパティチェックは止まらない

satisfies は「この値がこの型を満たしているか検証しつつ、推論された細かい型は保つ」ための演算子です。as の代わりに使えると思われがちですが、過剰プロパティチェックは satisfies でも通常どおり働きます。エラー回避の手段にはなりません。

satisfies.ts
// error TS2561: Object literal may only specify known properties,
// but 'colour' does not exist in type 'ButtonProps'.
// Did you mean to write 'color'?
const p5 = { label: 'a', colour: 'red' } satisfies ButtonProps;

// 正しく書けば通り、しかも color は string に確定する
const p6 = { label: 'a', color: 'red' } satisfies ButtonProps;
const c: string = p6.color;   // string | undefined ではない

むしろこれは satisfies の利点です。const p6: ButtonProps = ... と型注釈で書くと p6.color の型は宣言どおり string | undefined になりますが、satisfies なら検証を受けつつ string という具体的な推論結果が残ります。定数テーブルや設定オブジェクトを定義するときに向いています。

余分なプロパティを本当に許したいときの型設計

「この型には、決められたプロパティに加えて任意の項目を自由に足してよい」という仕様が本当にあることもあります。HTML 要素に渡す data-* 属性、プラグインの追加設定、ログに載せる任意のメタ情報などです。この場合は回避策で場当たり的に黙らせるのではなく、型に「任意のプロパティを受け付ける」と書いてしまうのが正解です。

index-signature.ts
interface ButtonPropsLoose {
  label: string;
  color?: string;
  [key: string]: unknown;   // 任意のプロパティを受け付ける
}

// エラーにならない
const p4: ButtonPropsLoose = { label: 'a', colour: 'red', 'data-id': 3 };

// label は string のままなので、既知のプロパティの安全性は保たれる
const len: number = p4.label.length;

インデックスシグネチャの型は any ではなく unknown にしておくのがおすすめです。any にすると未知のプロパティを読んだ先で何をしても型チェックが効かなくなりますが、unknown なら使う前に typeof などで確かめることを強制できます。既知のプロパティ(この例の label)は宣言どおりの型を保つので、そこだけは従来どおり守られます。

元の型を変更できない場合は、交差型で拡張する手もあります。Record<string, unknown> と組み合わせれば、既存の型定義に触れずに緩めた別名を作れます。

record-intersection.ts
// ライブラリ由来などで書き換えられない型を、その場で緩める
type WithExtra = ButtonProps & Record<string, unknown>;

const p8: WithExtra = { label: 'a', colour: 'red', trackingId: 'abc' };

ただし、どちらの方法もその型を使うすべての場所で打ち間違いが検出されなくなるという代償を伴います。「1箇所で余分な項目を渡したい」だけのために型全体を緩めるのは割に合いません。緩めるのはあくまで、仕様として任意のプロパティが正当に存在する型に限りましょう。追加してよい項目がある程度決まっているなら、[key: string]: unknown ではなく meta?: Record<string, unknown> のように余分なデータの置き場を1つのプロパティにまとめるほうが、はるかに扱いやすい設計になります。

has no properties in common と言われたときは別の仕組み

過剰プロパティチェックとよく似た、しかし別のエラーに TS2559 があります。すべてのプロパティが省略可能な型(weak type)に対して、共通するプロパティを1つも持たない値を代入したときに出るものです。

weak-type.ts
interface FetchOptions {
  method?: string;
  headers?: Record<string, string>;
}

function request(o: FetchOptions) {}

const opts = { verb: 'POST' };   // method の書き間違い

// error TS2559: Type '{ verb: string; }' has no properties in common
// with type 'FetchOptions'.
request(opts);

注目してほしいのは、これは変数経由でも検出される点です。すべてのプロパティが省略可能な型は、構造的部分型のルールだけで判断するとほとんど何でも受け入れてしまいます。その穴を塞ぐために weak type detection という別のチェックが用意されているわけです。オプション引数を受け取る関数を書くときは、この保護が働くことを覚えておくと、原因不明の TS2559 に悩まされずに済みます。

逆に、共通するプロパティが1つでもあれば通ってしまうので、{ method: 'POST', headrs: {} } のようなケースは変数経由だと素通りします。オプションオブジェクトはできるだけリテラルのまま関数に渡し、過剰プロパティチェックの恩恵を受けるようにしてください。

まとめ

過剰プロパティチェックは、その場で書かれたオブジェクトリテラル(fresh な型)を別の型に代入するときだけ働く追加の検査で、TS2353 または TS2561(似た名前の候補があるとき)として現れます。構造的部分型のルールでは余分なプロパティがあっても代入できてしまうため、colourcolor のような打ち間違いを見逃さないようにする安全網として用意されています。変数に入れると印が失われてエラーが消えるのはこのためで、スプレッドで展開した部分も同じ理由でチェックされません。ユニオン型では「どのメンバーにも無いプロパティ」だけがエラーになりますが、判別可能なユニオンでは1つのメンバーに絞り込まれてから厳密に判定されます。エラーが出たときは、まず型定義の側が正しいかを疑ってください。回避策としては変数経由・as・インデックスシグネチャがありますが、いずれも今後の打ち間違いを検出できなくする代償を伴います。satisfies はチェックを止める手段ではなく、検証を受けたうえで細かい推論結果を残すための道具です。任意のプロパティを許すことが仕様であるなら、[key: string]: unknownRecord<string, unknown> で型に明記するか、meta のような1つのプロパティに逃がす設計を検討しましょう。

参考ページ