TypeScript をより安全に使うために その 3: オブジェクトの index signature とオプショナルなプロパティを避ける

どんどんタイトルが長くなっている. 前回はこちらです.

susisu.hatenablog.com

引き続き環境は以下を前提とします:

  • TypeScript 4.4 (この記事を書いている 2021 年 11 月時点の最新版)
  • strict: true

おさらい

Index signature

オブジェクト型に index signature が含まれる場合, 任意の string, number, symbol を用いたプロパティの参照が可能となります. 標準の機能では Array のインデックスアクセスなどで使用されています.

type Dictionary = { [key: string]: number };

const dict: Dictionary = {};
dict["foo"] = 42;

const foo: number = dict["foo"]; // = 42

Mapped types とは記法が似ており, また mapped type の結果が index signature を持つオブジェクト型となることもありますが, 別の機能です.

Index signature はざっくり言えばオブジェクトのプロパティを使って辞書を表現するための機能です. ただし, 辞書やそれを応用した集合を表現するには, Map (ReadonlyMap) や Set (ReadonlySet) なども利用できることも覚えておきましょう.

オプショナルなプロパティ

オブジェクト型にオプショナルなプロパティを含めることで, 存在しないかもしれないプロパティを表現することができます.

type Options = {
  foo?: number;
  bar?: string;
};

// どれも OK
const a: Options = {};
const b: Options = { foo: 42 };
const c: Options = { bar: "xxx" };
const d: Options = { foo: 42, bar: "xxx" };

コンパイラオプションで exactOptionalPropertyTypes を有効化していない限りは, オプショナルなプロパティに対して undefined を代入することもできます.

const e: Options = { foo: 42, bar: undefined };

プロパティを参照する場合は undefined を含む型となります.

const foo: number | undefined = a.foo;

オプショナルなプロパティを使うことで, 存在しないかもしれないデータを表現することができます. ただし, 他にも存在しないかもしれないデータを表現する方法はいくつかあり, undefined との union がその一つとして挙げられます.

type OptionalData = {
  foo: number | undefined;
  bar: string | undefined;
};

このようにした場合, プロパティの省略はできなくなります. undefined が含まれるという点で型としてはよく似ていますが, 単に記法が異なるというだけではありません.

const x: OptionalData = {};                          // Error
const y: OptionalData = { foo: 42 };                 // Error
const z: OptionalData = { foo: 42, bar: undefined }; // OK

原則: index signature とオプショナルなプロパティを避ける

オブジェクトの index signature とオプショナルなプロパティは, それぞれ辞書や存在しないかもしれないデータを表現するための方法です.

一方で上にも挙げたとおり, こういった用途には代替の方法もいくつか存在します:

  • 辞書
    • index signature
    • Map (ReadonlyMap) や Set (ReadonlySet)
    • その他ライブラリの提供する辞書型
  • 存在しないかもしれないデータ
    • オプショナルなプロパティ
    • undefinednull との union
    • その他ライブラリの提供するオプション型

ある状況で index signature やオプショナルなプロパティを使用するとしたら, これらの選択肢の中で最も適切であると判断された場合に限るべきです. 実際, 以下で紹介するように index signature とオプショナルなプロパティには他の選択肢と比較すると罠が多く, デフォルトの選択肢とするには不適切な場面が多いです.

したがって, index signature やオプショナルなプロパティが使えそうな場面では, まずは他の手段が利用できないかも検討しましょう. それでもこれらを使いたい場合は, デメリットを考慮した上で注意して使用しましょう. このようにすると自ずと使用する場面は限定的になっていくはずです.

以下では index signature やオプショナルなプロパティを注意せず使用するとどのような危険性があるのか, そしてどういった場合であれば妥当な使用といえるのか, いくつか具体例を紹介します.

危険な使用の例

Index signature を持つオブジェクト型へのキャスト

もしオブジェクトの持つプロパティ全てが index signature と互換性があれば, その index signature を持つ型の値として扱って問題ないはずです.

実際に以下の例では, 型 ADictionary の index signature と互換性のないプロパティ bar: string を持っているため, Dictionary 型の変数への代入はエラーになりますが, 型 B はすべてのプロパティに互換性があるため, Dictionary 型の変数へ代入を行うことができます.

type Dictionary = { [key: string]: number };

type A = { foo: number; bar: string };
const a: A = { foo: 0, bar: "xxx" };
const x: Dictionary = a; // Error: Property 'bar' is incompatible with index signature.

type B = { foo: number };
const b: B = { foo: 1 };
const y: Dictionary = b; // OK

ところで A はよりプロパティの少ない B の部分型であることを思い出すと, aB の値として扱うことができます. そしてこのようなキャストが挟まった場合, 続けて Dictionary へのキャストも行うことができてしまいます.

const c: B = a;
const z: Dictionary = c; // OK

const bar: number = z["bar"]; // = "xxx"

このように, TypeScript では型の上にオブジェクトの持つ全てのプロパティが現れているとは限らない一方で, index signature を持つオブジェクト型へのキャストはその (事実とは異なる) 前提に基づいて行われるため, 一般には安全ではありません. 逆に言えば, 安全性を高めるためには, index signature はこういったキャストが入り込む余地がない場面でのみ使用すべきです.

オプショナルなプロパティを持つオブジェクト型へのキャスト

もし特定のプロパティをオブジェクトが持っていなければ, そのプロパティがオプショナルであるオブジェクト型の値として扱って問題ないはずです.

以下の例では, 型 A のプロパティ bar: stringOptions のプロパティ bar?: boolean は互換性がないため, 型 A の値を Options 型の変数へ代入しようとするとエラーとなりますが, 型 B はすべてのプロパティに互換性があるため, 代入が可能です.

type Options = { foo?: number; bar?: boolean };

type A = { foo: number; bar: string };
const a: A = { foo: 0, bar: "xxx" };
const x: Options = a; // Error: Types of property 'bar' are incompatible.

type B = { foo: number };
const b: B = { foo: 1 };
const y: Options = b; // OK

しかし index signature の例と同様に, aB にキャストすることができ, これを挟むことで最終的に Options へキャストできてしまいます.

const c: B = a;
const z: Options = c; // OK

const bar: boolean | undefined = z.bar; // = "xxx"

オプショナルなプロパティを持つオブジェクト型へのキャストについても, 型の上にオブジェクトの持つ全てのプロパティが現れているはずだという前提のもとで行われてしまうため, 一般には安全ではありません. 安全性を高めるためには, やはりキャストが入り込む余地のない場面のみで使用するのに留めるのが無難でしょう.

Object.prototype から継承されたプロパティの存在と index signature

普段意識することはないかもしれませんが, オブジェクトリテラルを使って作成したオブジェクトにも Object.prototype からプロパティ (メソッド) が継承されています. これらは型の上に明には現れていなくても存在しているものとして扱われており, 実は特別な手順なく参照することもできます.

const obj: {} = {};
console.log(obj.toString()); // "[object Object]"

ところがこういった Object.prototype から継承されたプロパティと index signature との互換性については, TypeScript のコンパイラは考慮しません. おそらく以下のようなパターンが頻出であるため, 意図的に無視しているのでしょう.

const dict: { [key: string]: number } = {};

ここで dict["toString"] を参照すると, 当然 Object.prototype から継承されたメソッド (関数) が取得されますが, 型としては index signature で指定された number 型となってしまいます.

const x: number = dict["toString"]; // = Object.prototype.toString

ちなみに [] ではなく . を使って参照すると関数型になったりします. これも TypeScript がいい感じに空気を読んでいるためだと思われますが, 一貫性がなくて面白いですね.

const y: () => string = dict.toString;

さて, こういった Object.prototype から継承されたプロパティとの干渉を避けるためには,

  • プロパティの参照時には Object.prototype.hasOwnProperty を用いた検査をするか, Object.entries などで列挙されたもののみを使う
  • プロパティの更新時には __proto__ の存在を考慮して Object.defineProperty などを使う
  • あるいは Object.create(null) を使う (タイトル回収)

といったことに常に気をつけておく必要があります. これらは素の JavaScript を書く場合にも注意すべきことで, そして誤るとしばしば重大なバグにもつながってしまうことですが, TypeScript においてもそれは特に変わりはありません.

こういったことからも, 辞書として index signature を持つオブジェクトを使用することは推奨できません. 代替手段である MapSet にはこのような問題は存在しないため, まずはこれらを使用することを検討すべきです.

おまけ: index signature の境界外アクセス

Index signature についてよく槍玉に挙げられているのは境界外アクセス, つまり存在しないプロパティの参照です. この場合, 値は undefined となりますが, TypeScript のデフォルトの設定では index signature で指定された型であるという扱いをされてしまいます.

const dict: { [key: string]: number } = {};
const xxx: number = dict["xxx"]; // = undefined

この問題に対しては noUncheckedIndexedAccess というコンパイラオプションが存在していて, 有効化するとプロパティ参照時の型に undefined を含めることができます. もし型による安全性を極めたければこのオプションを有効化しておきましょう.

一方で, この手の安全でない参照はどの言語にも存在する特に珍しくもない機能なので, この記事ではオプションの有効化を特に推奨することはしません. TypeScript の場合, 存在しないプロパティを参照しても直ちに例外とはならない点には注意が必要です.

Map を使用している場合は get メソッドの戻り値の型に undefined が含まれているため, この問題は発生しません.

おまけ: オプショナルなプロパティを使ったパターンの表現

全く異なる複数のパターンのオブジェクトを表現するために, オプショナルなプロパティを使用するという例がまれに見られます.

type Event = {
  // userJoined, userLeft, messagePosted, messageDeleted のいずれか
  type: string;
  // userJoined, userLeft でのみ存在
  userId?: string;
  name?: string;
  // messagePosted, messageDeleted でのみ存在
  messageId?: string;
  // messagePosted でのみ存在
  content?: string;
};

こういった用途でオプショナルなプロパティを使ってしまうと, プロパティの参照時には as! を使ったキャストが行われるか, あるいは実行時のバリデーションが必要となり, 型で静的に安全性が保証されているのとは程遠い状態になってしまいます.

if (event.type === "messagePosted") {
  const name: string = event.name!; // 必ず存在するはず
  print(`WELCOME, ${name.toUpperCase()}!`);
}

こういった場合は union, 特に discriminated union を使って表現するのがより適切な方法です.

type Event =
  | { type: "userJoined"; userId: string; name: string }
  | { type: "userLeft"; userId: string; name: string }
  | { type: "messagePosted"; messageId: string; content: string }
  | { type: "messageDeleted"; messageId: string }
if (event.type === "userJoined") {
  const name: string = event.name; // コンパイラによって型が絞り込まれるのでキャストは不要
  print(`WELCOME, ${name.toUpperCase()}!`);
}

妥当な使用の例

JSON でやりとりするデータを表現する場合

JSON で辞書のようなデータを表現する場合, MapSet といったデータ型は存在しないため, 必然的に素のオブジェクトを使って表現することになります.

また, 存在しないかもしれないデータを表す方法についても, JSON ではプロパティが存在しないことで表現しているかもしれません. 特に外部のサービスとのやりとりをする場合などには, たとえ扱いづらくても受け入れるしか選択肢がない場合もあるでしょう.

このような場合に index signature やオプショナルなプロパティを使って型を記述するのは, 最も正確な方法であり, 妥当な選択であると言えます. 一方で上記のような危険性がなくなっているわけではないので, 可能ならば JSON を受け取った直後に安全な形に変換しておくことをおすすめします.

安全さよりも記法の簡便さを優先したい場合

オブジェクトリテラルを使った場合と比べて, Map を使った場合は, 同じ内容を表すデータであっても記述がやや冗長になってしまいます.

const obj: { [key: string]: number } = {
  foo: 0,
  bar: 1,
  baz: 2,
};

const map: Map<string, number> = new Map([
  ["foo", 0],
  ["bar", 1],
  ["baz", 2],
]);

特に既定値を与えるといった目的で辞書を必要とする場合に, 開発者体験を良くするため, 安全性と天秤にかけて index signature を選択するということもないわけではないでしょう. ただし天秤にかけずに選択してはいけません.

名前付きのオプショナルな関数のパラメータを表現する場合

関数の名前付きパラメータを表現する方法として, オブジェクトを引数に取る方法がよく使われます.

type MyFuncOptions = {
  foo: number;
  bar: string;
};

function myFunc(options: MyFuncOptions): void {
  // ...
}

myFunc({ foo: 42, bar: "xxx" });

このような名前付きのパラメータを必須ではなくする場合は, オプショナルなプロパティを使って表現するのが最も妥当です.

type MyFuncOptions = {
  foo?: number;
  bar?: string;
};

function myFunc(options?: MyFuncOptions): void {
  // ...
}

// どれも OK
myFunc();
myFunc(undefined);
myFunc({});
myFunc({ foo: 42 });
myFunc({ bar: "xxx" });
myFunc({ foo: 42, bar: "xxx" });
myFunc({ foo: 42, bar: undefined });

これは上の「安全さよりも記法の簡便さを優先したい場合」のパターンの一つと言えるかもしれませんが, この場合はオブジェクトリテラルを直接関数に渡しており, 危険な使用の例で挙げたようなキャストが入り込む余地がないため, より安全な使用方法であると言えます.

次回

まとめです.

susisu.hatenablog.com

TypeScript をより安全に使うために その 2: オブジェクトの具体的な形にアクセスするのを避ける

前回はこちら.

susisu.hatenablog.com

引き続き環境は以下を前提とします:

  • TypeScript 4.4 (この記事を書いている 2021 年 11 月時点の最新版)
  • strict: true

原則: オブジェクトの具体的な形にアクセスするのを避ける

ここで「オブジェクトの具体的な形にアクセスする」とは, 静的な型によらずに, 実行時にオブジェクトがどのようなプロパティを持っているかといった情報を取得することを指しています. ある種のリフレクションと呼んでも良いかもしれません.

こういった操作には, JavaScript / TypeScript 標準の機能では以下のようなものが含まれます:

TypeScript の設計方針として, 静的な型に関する情報は実行時には一切残らないようになっています.

Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.

したがって, 上のような機能が静的な型を参照して動作を変えることはなく, それゆえ部分型のように一つの値に複数通りの型付けが考えられる場合において, 実行時に取得したオブジェクトの具体的な形に関する情報は, そのオブジェクトの静的な型とは必ずしも完全には一致しません.

例えば以下のようにオブジェクトのプロパティが少なくなる方向にキャストが行われた場合, 静的な型には含まれていないように見えるプロパティであっても, 実行時に含まれていれば取得できてしまいます.

const x: { foo: number; bar: string } = { foo: 42, bar: "xxx" };
const y: { foo: number } = x;
console.log(Object.entries(y)); // => [["foo", 42], ["bar", "xxx"]]

その一方で, TypeScript の型システムやライブラリの型定義には, 上記のようなs機能で取得した具体的なオブジェクトの形に関する情報が, 静的な型と完全に一致することを仮定したふるまいをする箇所がいくつかあります. 実際, 上の例の Object.entries(y) の型は [string, number][] で, 見てのとおり安全ではありません. このような設計になっているのは, 前の記事でも述べたように利便性と天秤にかけて判断された結果なのでしょう.

こういった TypeScript の設計判断を鑑みると, 安全性を優先したい場合は, 基本的にはオブジェクトの具体的な形にアクセスする機能は避けておくのが賢明です. とはいえ全く利用しないというのは現実的ではないため, 以下のような指針を持って利用することを推奨します:

  • Object spread ... はオブジェクトリテラルの先頭でのみ使用する
  • Object.keys, Object.values, Object.entries は後述するような安全な場合に限定して使用する
  • その他は使用しない

以下ではなぜこのような指針で利用すべきなのか, つまりこれ以外の利用方法ではどのようにして安全性が損なわれてしまうのかについて, いくつか具体的に紹介していきます.

具体例

in 演算子による型の絞り込み

in 演算子を使うと, プロパティの有無に応じて変数の型を絞り込むこと (narrowing) が可能です.

例えば以下の関数 getUrl では, in 演算子によって引数 repo がプロパティ url を持っているかどうかに応じて型が絞り込まれています.

type GitRepository =
  | { url: string }
  | { owner: string; name: string };

function getUrl(repo: GitRepository): string {
  if ("url" in repo) {
    // repo: { url: string }
    return repo.url;
  } else {
    // repo: { owner: string; name: string }
    return `https://github.com/${repo.owner}/${repo.name}.git`;
  }
}

console.log(getUrl({ url: "https://example.com/repo.git" }));
// => "https://example.com/repo.git"

console.log(getUrl({ owner: "microsoft", name: "TypeScript" }));
// => "https://github.com/microsoft/TypeScript.git"

ここでオブジェクト型はプロパティがより少ない型の部分型であること思い出しましょう. つまり以下のように, GitRepository 型の 2 つめのパターン { owner: string; name: string } に余計にプロパティ xxx が付与されたような値は, アップキャストして GitRepository 型の値としても扱えることを思い出してみましょう.

const repo = {
  owner: "susisu",
  name: "typefuck",
  xxx: 666,
};
console.log(getUrl(repo));
// => "https://github.com/susisu/typefuck.git"

これだけであれば特に問題はありません. ではこの余計なプロパティの名前が, 1 つめのパターンのプロパティ url と衝突した場合はどうでしょうか.

const repo = {
  owner: "susisu",
  name: "typefuck",
  url: 666,
};
console.log(getUrl(repo));
// => 666

なんとこの場合も型エラーにはなりません. そして結果が期待したものではないことに加えて, getUrl の戻り値の型が string と宣言されているにも関わらず数値が返っており, 明らかに正しくない状態となってしまっています.

この問題は in 演算子が上の例ようなアップキャストは行われないものとして動作し, 型を絞り込んでしまうために起こります. これを防ぐためには, in 演算子を使ってプロパティの有無に応じた分岐をするのではなく, 各パターンに絞り込みのためのプロパティ (タグ) を含めておき, それを元に分岐を行うようにしましょう. このようにした union 型は discriminated unions または tagged unions と呼ばれており, TypeScript では極めてよく使うテクニックなので覚えておきましょう.

type GitRepository =
  | { type: "url"; url: string }
  | { type: "github"; owner: string; name: string };

function getUrl(repo: GitRepository): string {
  if (repo.type === "url") {
    // repo: { type: "url"; url: string }
    return repo.url;
  } else {
    // repo: { type: "github"; owner: string; name: string }
    return `https://github.com/${repo.owner}/${repo.name}.git`;
  }
}

const repo = {
  type: "github" as const,
  owner: "susisu",
  name: "typefuck",
  url: 666,
};
console.log(getUrl(repo));
// => "https://github.com/susisu/typefuck.git"

ちなみに discriminated union を使っていない元の例でも, 以下のように直接オブジェクトリテラルを関数に渡すと TypeScript はエラーを出してくれます. とはいえオブジェクトリテラルを直接渡すようにしたところで型に変化はないはずなので, これは純粋な型エラーというよりは TypeScript コンパイラによるお節介という側面が強いです.

// Types of property 'url' are incompatible.
console.log(getUrl({
  owner: "susisu",
  name: "typefuck",
  url: 666,
}));

もし引数をこのようにリテラルで渡すことしかない (名前付き引数として利用する) のであれば, わざわざタグを付与しないといけない discriminated union を使うよりも, 利便性を優先して in 演算子による絞り込みを選択することもないわけではないでしょう.

Object.keys の誤った使用

これは TypeScript ではなく人間が悪い例なのですが, しばしば見かけるのでここで紹介します.

例えば以下のような TypeScript のコードを考えてみましょう.

type Counts = { foo: number; bar: number; baz: number };

function calcTotal(counts: Counts): number {
  let total = 0;
  for (const key of Object.keys(counts)) {
    // key の型が string になってしまい, t[key] とはアクセスできない.
    // key の型は keyof Counts のはずなので as でキャストする.
    total += counts[key as keyof Counts];
  }
  return total;
}

const counts = { foo: 1, bar: 2, baz: 3 };
console.log(calcTotal(counts)); // => 6

ここで Object.keys の型定義は以下のようになっています (lib.es2015.core.d.ts より編集して抜粋).

interface ObjectConstructor {
  keys(o: {}): string[];
}

つまり Object.keys の戻り値の型は引数によらず常に string[] です. したがって上のコードでは key as keyof Counts のように as を使ったキャストが必要になってしまっていました.

さて勘の良い方ならもう気がついていると思いますが, 「key の型は keyof Counts のはず」という仮定は一般には誤りで, アップキャストによって関数の引数には Counts には列挙されていないプロパティが含まれる可能性があります.

const badCounts = { foo: 1, bar: 2, baz: 3, qux: "66" };
console.log(calcTotal(badCounts)); // => "666"

この例では TypeScript が折角安全な型を提供してくれているのに, 人間がそれを無視して as によるダウンキャストを行ってしまっていました. as は基本的には危険な道に足を踏み入れているサインなので, 余程の自信がなければ踏みとどまるようにしましょう.

ここでの calcTotal のような関数を書く場合は, プロパティの列挙に Object.keys を使うのではなく, 予め必要なプロパティを手で列挙しておくようにしましょう. これはとても面倒そうに聞こえるかもしれませんが, 実はプロパティの列挙を行いたいような状況では大抵は型を mapped type を使って定義できるので, 以下のように大きな手間なく記述できることが多いです.

// countKeys: readonly ["foo", "bar", "baz"]
const countKeys = ["foo", "bar", "baz"] as const;
// CountKey = "foo" | "bar" | "baz"
type CountKey = typeof countKeys[number];

// Counts = { foo: number; bar: number; baz: number };
type Counts = { [K in CountKey]: number };

function calcTotal(counts: Counts): number {
  let total = 0;
  for (const key of countKeys) {
    total += counts[key];
  }
  return total;
}

const badCounts = { foo: 1, bar: 2, baz: 3, qux: "66" };
console.log(calcTotal(badCounts)); // => 6

Object.values, Object.entries の使用

Object.entries は最初にも紹介しましたが, これらは戻り値の型と実際の値が矛盾してしまうことがあります.

const x: { foo: number; bar: string } = { foo: 42, bar: "xxx" };
const y: { foo: number } = x;

const vs: number[] = Object.values(y);
// = [42, "xxx"]
const es: [string, number][] = Object.entries(y);
// = [["foo", 42], ["bar", "xxx"]]

Object.values, Object.entries の型定義はそれぞれ以下のようになっています (lib.es207.core.d.ts より編集して抜粋).

interface ObjectConstructor {
  values<T>(o: { [s: string]: T } | ArrayLike<T>): T[];
  values(o: {}): any[];

  entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][];
  entries(o: {}): [string, any][];
}

それぞれ引数の型を元に戻り値の型を決定していますが, 上記のようなプロパティが減るようなアップキャストについては考慮されていません.

プロパティの列挙をしたい場合は基本的にはこれらを使うのではなく, Object.key の節で紹介したような, 予めプロパティを列挙しておく方法を使用しましょう. ただし例外として, 引数に対してプロパティが減少するアップキャストが行われていないことが保証できるのであれば, これらの関数を使用することに安全性の面での問題はありません.

また index signature を持つオブジェクトに対して使用するのであれば, アップキャストに伴う問題は発生しづらいため, ある程度許容できると言えます. ただし次回の記事で index signature について詳しく紹介しますが, これでも完全に安全であるというわけではありません. そもそもこのようなデータには Map を使うことも検討しましょう.

function calcTotal(counts: { [key: string]: number }): number {
  let total = 0;
  for (const value of Object.values(counts)) {
    total += value;
  }
  return total;
}

リテラルの先頭以外での object sperad

オブジェクトリテラルでの spread syntax ... は, オブジェクトのプロパティを列挙し, 新しく作られるオブジェクトへコピーします (以下「展開」と呼びます).

const a1 = { x: 0, y: "xxx" };

const b1 = { ...a1, z: true };
// b1: { x: number; y: string; z: boolean }
// b1 = { x: 0, y: "xxx", z: true }

この spread syntax がリテラルの先頭以外に書かれている場合, それよりも前に同名のプロパティが存在していれば上書きするという動作をします.

const a2 = { x: 0, y: 1 };
const b2 = { y: "xxx", z: true };

const c2 = { ...a2, ...b2 };
// c2: { x: number; y: string; z: boolean }
// c2 = { x: 0, y: "xxx", z: true }

Object spread を使って作られた新しいオブジェクトの型は, 展開されたオブジェクトの型を, 上書きの挙動に従ってマージしたものになっています.

ここで毎度おなじみのプロパティが減るアップキャストが行われた場合を考えてみましょう.

const a3 = { x: 0, y: "xxx" };
const b3: { x: number } = a3;

const c3 = { y: true, ...b3 };
// c3: { x: number; y: boolean }
// c3 = { x: 0, y: "xxx" }

TypeScript はオブジェクトの型に明示的に含まれているプロパティのみが展開されるかのようにして型を決定しますが, 当然ながら実行時の挙動としては全ての存在するプロパティについて展開してしまいます. そのため, この例では型の上では現れない b3.y が前に定義された y の値を上書きしてしまい, 結果として c3.y の型と値が矛盾してしまいました.

このような矛盾を引き起こさないためには, プロパティの上書きの挙動を引き起こさないようにすれば良いはずです. これはオブジェクトリテラルの先頭でのみ spread syntax を許すことによって実現できます.

const a4 = { x: 0, y: "xxx" };
const b4: { x: number } = a4;

const c4 = { ...b4, y: true };
// c4: { x: number; y: boolean }
// c4 = { x: 0, y: true }

もちろん例外として, 上記のようなプロパティが減るアップキャストが起こっていないことが保証できていれば, 先頭以外で使用しても大丈夫です.

次回

susisu.hatenablog.com