Record<string, any>としても実はstring以外のキーも入る
始めに
以下のようなオブジェクトをフィールドごとに描画方法をカスタマイズして一括で描画できるようにするメソッドを作った際に、Reactのkeyのところで symbol
は代入できないというエラーが出ました。
type RenderItemByKey<Data extends Record<string, any>, K extends keyof Data> = {
fieldKey: K
label: string
render?: (value: Data[K], data: Data) => React.ReactNode
}
type RenderItem<Data extends Record<string, any>> = {
[K in keyof Data]: RenderItemByKey<Data, K>
}[keyof Data]
const renderDataFields = <Data extends Record<string, any>>(data: Data, items: RenderItem<Data>[]) => {
return (
<dl>
{items.map((item) => {
const value = data[item.fieldKey]
const content = item.render ? item.render(value, data) : value
return (
// Type 'symbol' is not assignable to type 'Key | null | undefined' というエラーが出る
<React.Fragment key={item.fieldKey}>
<dt>{item.label}</dt>
<dd>{content}</dd>
</React.Fragment>
)
})}
</dl>
)
}
使用例
type Person = {
id: number
name: string
age: number
sex: 'male' | 'female'
}
const person: Person = {
id: 0,
name: '山田太郎',
age: 20,
sex: 'male'
}
const RENDER_ITEMS: RenderItem<Person>[] = [
{ fieldKey: 'id', label: 'ID' },
{ fieldKey: 'name', label: '名前' },
{
fieldKey: 'age',
label: '年齢',
render: (value) => {
return `${value}歳`
}
},
{
fieldKey: 'sex',
label: '性別',
render: (value) => {
return value === 'mail' ? '男性' : '女性'
}
}
]
renderDataFields(person, RENDER_ITEMS)
とりあえずkeyに渡す部分をString
でラップすることでstringに変換してくれるのでエラーは解消されますが、なんでこんな挙動になるのか気になったので調査したのでそれをまとめました。
const renderDataFields = <Data extends Record<string, any>>(data: Data, items: RenderItem<Data>[]) => {
return (
<dl>
{items.map((item) => {
const value = data[item.fieldKey]
const content = item.render ? item.render(value, data) : value
return (
- // Type 'symbol' is not assignable to type 'Key | null | undefined' というエラーが出る
- <React.Fragment key={item.fieldKey}>
+ // Stringでラップすることでtypeエラーは解消できる
+ <React.Fragment key={String(item.fieldKey)}>
<dt>{item.label}</dt>
<dd>{content}</dd>
</React.Fragment>
)
})}
</dl>
)
}
Record<string, any>
としても実はstring以外のキーも入る
結論から言うとかなり衝撃だったのですが、そもそもRecord<string, any>
としてもsymbolやnumberをキーにしても代入できていました。 この仕様に関するドキュメントを見つけられませんでしたが、なんとなくオブジェクトにSymbol
やnumber
をキーとして入れると自動でtoString
が実行されてstring
として扱われるため、Record<string, any>
とすると全てのキーが対象になってしまうのかなと思いました🤔
const sym = Symbol();
// Symbolのオブジェクトを代入できる
const symbolObj: Record<string, any> = { [sym]: 'hoge' }
// numberのオブジェクトも代入できる
const numberObj: Record<string, any> = { [10]: 'hoge' }
従って最初に書いていたコードもそもそもシンボルをキーに設定することが可能な状態だったため、シンボルが入ることも考慮した実装にする必要がありました。
const sym = Symbol();
type SymbolObj = {
[sym]: typeof sym
}
const symObj: SymbolObj = {
[sym]: sym
}
const RENDER_SYMBOL_OBJ_ITEMS: RenderItem<SymbolObj>[] = [
{
// エラーにならず指定できる
fieldKey: sym,
label: 'シンボル'
}
]
renderDataFields(symObj, RENDER_SYMBOL_OBJ_ITEMS);
stringのみに絞る方法
Record<string, any>
は実はSymbolやnumberもキーにできるようで、実質的に object
と同じであることが分かりました。今回のユースケースだとstringだけ受け付けたい場所にString
でラップするだけでも良いかなと思いましたが、どうしてもstringだけに絞りたい時の方法を色々調べてみました。
fieldKeyをstringだけに絞る
Record<string, any>
はSymbolやnumberも入るため、 keyof Record<string, any>
は string | number | symbol
になります。ここに string
でインターセクションをかけると string
のみになるため、これでstringだけに絞りたいと思います。今回の例だとunionを作るところに & string
を足すことでstringのfieldKeyだけのパターンだけ抽出され、結果的にそれ以外のfieldKeyは設定出来なくなります。
type RenderItem<Data extends Record<string, any>> = {
[K in keyof Data]: RenderItemByKey<Data, K>
-}[keyof Data]
+}[keyof Data & string] // keyof の中で更にstringのものだけに絞り込む
const RENDER_ITEMS: RenderItem<Person>[] = [
{
// シンボルのパターンは取り除かれたのでエラー
fieldKey: sym,
label: 'シンボル'
}
]
ジェネリクスにKeyを追加してそこにstring制約をかける
Record<string, any>
ではキーをstringに絞れないため、前段に更にジェネリクスを追加して、そこにstring制約を設定することでキーをstringだけに制限することができます。
-type RenderItem<Data extends Record<string, any>> = {
- [K in keyof Data]: RenderItemByKey<Data, K>
-}[keyof Data]
+type RenderItem<Key extends string, Data extends Record<Key, any>> = {
+ [K in Key]: RenderItemByKey<Data, K>
+}[Key]
これによって固くはなりましたが、引数が一つ増えて定義がちょっと手間になったのと、keyofを使わず期待していないキーを設定することもできるようになり、むしろ抜け道がありそうでちょっと微妙かなと思いました。
const RENDER_ITEMS: RenderItem<
// string以外のキーがあるとエラーになる
keyof Person,
Person
>[] = [
...
]
終わりに
以上が Record<string, any>
で絞っているのにキーがsymbolも入っていた理由と対処方法でした。Record<string, any>
と書いているのにstringで絞れなかったのは衝撃ですが、symbolが入っていても基本的にはString
でラップすれば良いのでそこまで実装に困らなそうだなと思いました。どうしてもstringで絞りたい場合は & string
とstringでインターセクションすると良いと分かったため、困ったときはそれで対処しようかなと思いました。
最後に検証コードをTypeScript Playgroundに書きましたので詳細のコードが気になる方はこちらをご参照いただけると幸いです。
Discussion