どんどんタイトルが長くなっている. 前回はこちらです.
引き続き環境は以下を前提とします:
- 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
)- その他ライブラリの提供する辞書型
- 存在しないかもしれないデータ
- オプショナルなプロパティ
undefined
やnull
との union- その他ライブラリの提供するオプション型
ある状況で index signature やオプショナルなプロパティを使用するとしたら, これらの選択肢の中で最も適切であると判断された場合に限るべきです. 実際, 以下で紹介するように index signature とオプショナルなプロパティには他の選択肢と比較すると罠が多く, デフォルトの選択肢とするには不適切な場面が多いです.
したがって, index signature やオプショナルなプロパティが使えそうな場面では, まずは他の手段が利用できないかも検討しましょう. それでもこれらを使いたい場合は, デメリットを考慮した上で注意して使用しましょう. このようにすると自ずと使用する場面は限定的になっていくはずです.
以下では index signature やオプショナルなプロパティを注意せず使用するとどのような危険性があるのか, そしてどういった場合であれば妥当な使用といえるのか, いくつか具体例を紹介します.
危険な使用の例
Index signature を持つオブジェクト型へのキャスト
もしオブジェクトの持つプロパティ全てが index signature と互換性があれば, その index signature を持つ型の値として扱って問題ないはずです.
実際に以下の例では, 型 A
は Dictionary
の 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
の部分型であることを思い出すと, a
は B
の値として扱うことができます.
そしてこのようなキャストが挟まった場合, 続けて Dictionary
へのキャストも行うことができてしまいます.
const c: B = a; const z: Dictionary = c; // OK const bar: number = z["bar"]; // = "xxx"
このように, TypeScript では型の上にオブジェクトの持つ全てのプロパティが現れているとは限らない一方で, index signature を持つオブジェクト型へのキャストはその (事実とは異なる) 前提に基づいて行われるため, 一般には安全ではありません. 逆に言えば, 安全性を高めるためには, index signature はこういったキャストが入り込む余地がない場面でのみ使用すべきです.
オプショナルなプロパティを持つオブジェクト型へのキャスト
もし特定のプロパティをオブジェクトが持っていなければ, そのプロパティがオプショナルであるオブジェクト型の値として扱って問題ないはずです.
以下の例では, 型 A
のプロパティ bar: string
と Options
のプロパティ 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 の例と同様に, a
は B
にキャストすることができ, これを挟むことで最終的に 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 を持つオブジェクトを使用することは推奨できません.
代替手段である Map
や Set
にはこのような問題は存在しないため, まずはこれらを使用することを検討すべきです.
おまけ: 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 で辞書のようなデータを表現する場合, Map
や Set
といったデータ型は存在しないため, 必然的に素のオブジェクトを使って表現することになります.
また, 存在しないかもしれないデータを表す方法についても, 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 });
これは上の「安全さよりも記法の簡便さを優先したい場合」のパターンの一つと言えるかもしれませんが, この場合はオブジェクトリテラルを直接関数に渡しており, 危険な使用の例で挙げたようなキャストが入り込む余地がないため, より安全な使用方法であると言えます.
次回
まとめです.