objectとRecord<K, T> / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の15日目です。昨日は『Required<T>
』を紹介しました。
Record<K, T>
Record<K, T>
はとても基礎的なMapped Typesを扱うUtility Typesのひとつです。TypeScript 2.1で追加されました。
実装を確認しましょう。
type Record<K extends keyof any, T> = {
[P in K]: T;
};
このように、とてもシンプルなMapped Typesの様相です。いくつか具体例をみてみましょう。
type T11 = Record<string, string>;
type T12 = Record<number, number>;
type T13 = Record<"hello" | "world", string>;
type T14 = Record<boolean, string>;
// Error: Type 'boolean' does not satisfy the constraint 'string | number | symbol'.(2344)
const obj11: T11 = {
a: "A",
b: "B",
};
const obj12: T12 = {
[-1]: -1,
0: 0,
1: 1,
};
const obj13: T13 = {
hello: "HELLO",
world: "WORLD",
hola: "HOLA",
// Error: Type '{ hello: string; world: string; hola: string; }' is not assignable to type 'T13'.
// Object literal may only specify known properties, and 'hola' does not exist in type 'T13'.(2322)
};
console.log(obj11.a); // "A"
console.log(obj12[-1]); // -1
T11
, T12
はごく単純な例です。Record<string, string>
は、すべてのプロパティに文字列が代入されうる任意の文字列をプロパティ名として持つオブジェクト型という表現です。Record<number, number>
も同様で、すべてのプロパティに数値が代入されうる任意の数値をプロパティ名として持つオブジェクト型という表現です。
K
にUnionを渡すこともできます。T13
の例では、hello
プロパティとworld
プロパティを持ち、それぞれのプロパティには文字列が格納されうるオブジェクト型という定義をしています。そのためhello
とworld
ではないプロパティを宣言するとエラーになります。
T14
はエラーです。これは注意すべき点としてK
がany
ではなくkeyof any
となっていることが挙げられます。オブジェクトのプロパティに指定できる型はstring | number | symbol
と定められています。そのためプロパティにboolean
を渡すことはできません。
プロパティとして指定しうる値の型
keyof any
とはどういうことか、もう少し探究してみましょう。ECMAScript仕様書によれば、The Object Typeとして定義があります。
一部を引用します。
A property key value is either an ECMAScript String value or a Symbol value.
An integer index is a String-valued property key that is a canonical numeric string and whose numeric value is either +0𝔽 or a positive integral Number ≤ 𝔽(253 - 1).
ということで、"A property key value"と"An integer index"という異なる表現ではありますが、最終的にオブジェクトにはstring | number | symbol
の値が取られうることが示されています。
undefined
リスクに注意
Record<K, T>
として推論されたオブジェクトは、そのK
を満たすあらゆるプロパティアクセスを許容してしまいます。例えば次のコードです。
type T21 = Record<string, string>;
const obj21: T21 = {
a: "A",
b: "B",
};
console.log(obj21.a); // "A"
console.log(obj21.z); // undefined (No error)
ここでobj21.z
は値を格納されていませんがアクセスは可能で、undefined
を得ています。このようにK
を満たすようであれば、すべてのプロパティアクセスを許容してしまいます。
function double(str: string): string {
return str.repeat(2);
}
console.log(double(obj21.a)); // "AA"
console.log(double(obj21.z));
// TypeError: Cannot read properties of undefined (reading 'repeat')
たちが悪いことに、この定義では引数型アノテーションの制約を貫通してしまいます。obj21.z
はundefined
であるにも関わらず、Record<string, string>
であるためにdouble()
関数の引数str: string
を満たしてしまいます。その結果、TypeScriptでは起こってほしくないCannot read properties of undefined
が発生してしまいます。
この状況を防ぎたければ、Record<string, string | undefined>
と定義すべきです。あるいは、Record<T, K>
を使わずにMap<K, V>
の採用を検討します。
縁の下の力持ち
先の例だとRecord<K, T>
はそんなに使うことないかも?という印象に映ったかもしれません。たしかに任意の文字列をプロパティに取り、値の型はすべて同じという状況はなかなか少ないかもしれないし、K
にUnionを指定するならばよくみるオブジェクト型の定義として書き下せばよいと感じます。undefined
のリスクも負うことになります。
Record<K, T>
はたしかに業務で頻繁に使うものではないのですが、重要な部分を下支えしています。それが「任意のオブジェクト」と「プロパティをひとつも含まないオブジェクト」という2つの表現です。
任意のオブジェクト
TypeScriptでは、型パラメータの制約としてSomething<T extends ...>
のようにT
はどういった型であるべきかを示すことができます。この仕様はGeneric Constraintsといい、以前紹介しました。
ここで、T
はnull
ではない何らかのオブジェクト型であってほしいという表現をしたいときに、Record<keyof any, unknown>
と書くことができます。オブジェクト型がプロパティのキーとして取りうるすべての型を許容し、そのプロパティの値の型はunknown
であるという指定です。
ただしこれは明らかに冗長で、こうする必要はないです。
この場合は単にextends object
とするだけでよいです。object
型をわざわざ単独で使うことはめったにないのですが、この状況では採用できます。なおextends Object
と大文字のObject
を使用しては絶対になりません。これはTypeScriptの公式ドキュメント上でも明確に禁止されています。
同様にNumber
, String
, Boolean
, Symbol
も記述してコンパイルエラーにならないとしても、作法上使用してはなりません。これはECMAScriptの歴史的経緯から残っていますが、現代のTypeScriptプログラミングにおいて使用すべき理由は一切なく、公式が禁止する規則に従うべきです。
プロパティをひとつも含まないオブジェクト
任意のオブジェクトの表現にはRecord<K, T>
を使わずobject
で十分と紹介しました。一方でプロパティをひとつも含まないオブジェクトという表現にはRecord<K, T>
が有用です。
このような用途ではRecord<string, never>
という表現が使えます。どんな文字列をキーとするプロパティでも値はnever
型である、つまり存在しえないという表現であるため、結果的にプロパティをひとつも含まない空のオブジェクトという表現として扱えます。
この表記はtypescript-eslint@ban-types
のルールとしても紹介されている手法です。すなわちSomething<T extends {}>
というような{}
の使い方をしてはならないという規則です。
これは{}
が「任意のオブジェクト」を意図しているのか「プロパティをひとつも含まないオブジェクト」を意図しているのか、実装者以外の開発者に伝わらないためです。そのため、object
やRecord<string, never>
を使うよう推奨されます。
なお、これは余談ですが前節で話題としたString
やBoolean
などの大文字プリミティブ型名の使用もban-types
で防ぐことができますので、ぜひ採用すべきルールです。ban-types
ルールはrecommendedであるため、比較的多くの案件で採用されていることから、見たことがある読者もおられると思います。
業務ではあまり使わない
Record<K, T>
をあえて使う場面は筆者の経験上あまりないです。こういった用途であれば意図的にMap<K, V>
を採用することのほうが多いからです。
では全く使わないかというと、インメモリキャッシュとして使う用途があったりします。例えばSWRでは実装内でRecord<K, T>
を積極的に採用している様子が確認できます。
この辺りについても別にMap<K, V>
でもよいという判断もできますが、ひとつには開発者の好みだったり、有意な速度差があるだったり、Map<K, V>
を採用できない実行環境(たとえばものすごく古い)を考慮しているだったり、様々な事情が推察できます。そのためRecord<K, T>
を選択する状況自体はあり得ると思いますし、これは開発者、開発チーム間での合意を優先するとよく、もし迷うようであれば仲間に相談するのがよいと思います。
intrinsic
とTemplate Literal Types』
明日は『本日はとても単純そうに見えるRecord<K, T>
と、その裏にあるオブジェクト型の重要な観点について紹介しました。数日に渡って紹介したMapped Typesを扱うUtility Typesは以上です。
明日は少し離れてintrinsic
というワードを取り上げ、Template Literal Typesの面白さについて紹介します。それではまた。
Discussion