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