🎄

objectとRecord<K, T> / TypeScript一人カレンダー

2022/12/23に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の15日目です。昨日は『Required<T>』を紹介しました。

Record<K, T>

Record<K, T>はとても基礎的なMapped Typesを扱うUtility Typesのひとつです。TypeScript 2.1で追加されました。

https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type

実装を確認しましょう。

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プロパティを持ち、それぞれのプロパティには文字列が格納されうるオブジェクト型という定義をしています。そのためhelloworldではないプロパティを宣言するとエラーになります。

T14はエラーです。これは注意すべき点としてKanyではなくkeyof anyとなっていることが挙げられます。オブジェクトのプロパティに指定できる型はstring | number | symbolと定められています。そのためプロパティにbooleanを渡すことはできません。

プロパティとして指定しうる値の型

keyof anyとはどういうことか、もう少し探究してみましょう。ECMAScript仕様書によれば、The Object Typeとして定義があります。

https://tc39.es/ecma262/#sec-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.zundefinedであるにも関わらず、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といい、以前紹介しました。

ここで、Tnullではない何らかのオブジェクト型であってほしいという表現をしたいときに、Record<keyof any, unknown>と書くことができます。オブジェクト型がプロパティのキーとして取りうるすべての型を許容し、そのプロパティの値の型はunknownであるという指定です。

ただしこれは明らかに冗長で、こうする必要はないです。

この場合は単にextends objectとするだけでよいです。object型をわざわざ単独で使うことはめったにないのですが、この状況では採用できます。なおextends Objectと大文字のObjectを使用しては絶対になりません。これはTypeScriptの公式ドキュメント上でも明確に禁止されています。

https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html#number-string-boolean-symbol-and-object

同様にNumber, String, Boolean, Symbolも記述してコンパイルエラーにならないとしても、作法上使用してはなりません。これはECMAScriptの歴史的経緯から残っていますが、現代のTypeScriptプログラミングにおいて使用すべき理由は一切なく、公式が禁止する規則に従うべきです。

プロパティをひとつも含まないオブジェクト

任意のオブジェクトの表現にはRecord<K, T>を使わずobjectで十分と紹介しました。一方でプロパティをひとつも含まないオブジェクトという表現にはRecord<K, T>が有用です。

このような用途ではRecord<string, never>という表現が使えます。どんな文字列をキーとするプロパティでも値はnever型である、つまり存在しえないという表現であるため、結果的にプロパティをひとつも含まない空のオブジェクトという表現として扱えます。

この表記はtypescript-eslint@ban-typesのルールとしても紹介されている手法です。すなわちSomething<T extends {}>というような{}の使い方をしてはならないという規則です。

これは{}が「任意のオブジェクト」を意図しているのか「プロパティをひとつも含まないオブジェクト」を意図しているのか、実装者以外の開発者に伝わらないためです。そのため、objectRecord<string, never>を使うよう推奨されます。

なお、これは余談ですが前節で話題としたStringBooleanなどの大文字プリミティブ型名の使用もban-typesで防ぐことができますので、ぜひ採用すべきルールです。ban-typesルールはrecommendedであるため、比較的多くの案件で採用されていることから、見たことがある読者もおられると思います。

https://typescript-eslint.io/rules/ban-types

業務ではあまり使わない

Record<K, T>をあえて使う場面は筆者の経験上あまりないです。こういった用途であれば意図的にMap<K, V>を採用することのほうが多いからです。

では全く使わないかというと、インメモリキャッシュとして使う用途があったりします。例えばSWRでは実装内でRecord<K, T>を積極的に採用している様子が確認できます。

https://github.com/vercel/swr/blob/9ea4a45c1620b31fb3a5a09771e0809638f47974/_internal/types.ts#L4

この辺りについても別に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