かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript Object.keys() が `string[]` になってしまう問題と戦う

環境
  • typescript: 5.1.3

Object.keys()string[] になってしまうのでブランケットアクセス (obj[key]) で TypeError

const idols = {
  ichigo: { unit: 'Soleil', type: 'cute' },
  aoi: { unit: 'Soleil', type: 'cool' },
  ran: { unit: 'Soleil', type: 'sexy' },
  yurika: { unit: 'Tristar', type: 'cool' },
  otome: { unit: 'Powapowa Puririn', type: 'pop' },
  akari: { unit: 'Luminus', type: 'cute' },
};

Object.keys(idols).map((name) => {
  return `${name}(${idols[name].unit})`;
});

idols[name].unit 部分でTypeError が発生する
=> Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ ichigo: { unit: string; type: string; }; aoi: ...; }'.
No index signature with a parameter of type 'string' was found on type '{ ichigo: { unit: string; type: string; }; aoi: ...; }'.
Unsafe member access .unit on ananyvalue. (eslint@typescript-eslint/no-unsafe-member-access)

Object.keys(idols)string[] と推論されるためブランケットアクセスしている idols[name] で TypeError が発生し any 型と推論される続く .unitUnsafe member access になってしまっている

Object.keys() の型をいい感じにする

  1. as (Type Assertion) を使う
  2. ラッパー関数を作成する
  3. ObjectKeys の型定義を上書きする

1. 返り値に as (Type Assertion) で型定義してしまう

  1. Object.keys(idols).map((name) => {})name が string 型として推論されている為に TypeError が発生している
  2. nameidols オブジェクトのいずれかのキーを表す型であれば問題ない
  3. idols のキーの Union 型は keyof typeof idols
  4. ブランケットアクセスしている nameaskeyof typeof idols 型だと解釈させればよい
Object.keys(idols).map((name) => {
  return `${name}(${idols[name as keyof typeof idols].unit})`;
});

cf. typescript - Element implicitly has an 'any' type because expression of type 'string' can't be used to index - Stack Overflow

シンプルな方法だが、name を使う箇所が増えるたびに Type Assertion を書く必要があり keyof typeof <OBJECT> で指定する変数を間違うと型エラーを握り潰してエラーがあるコードがデプロイされてしまう可能性もある

2. ラッパー関数を作成する

const objectKeys = <T extends { [key: string]: unknown }>(
  obj: T,
): (keyof T)[] => {
  const res = Object.keys(obj);
  return res;
  // => res は string[] 型だが (keyof T)[] として扱われる
};

objectKeys(idols).map((name) => {
  return `${name}(${idols[name].unit})`;
  // name は `name: "ichigo" | "aoi" | "ran" | "yurika" | "otome" | "akari"` 型に推論される
});

cf. 【TypeScript】Object.keys() の返り値をstring[]型でなくユニオン型の配列にしたい

ラッパー関数の返り値を infer などで制限すると Type Assertion が必要になってしまった

type ObjectKeys<T> = (keyof T extends infer U
  ? U extends keyof T
    ? U
    : never
  : never)[];

const objectKeys = <T extends { [key: string]: unknown }>(
  obj: T,
): ObjectKeys<T> => {
  const res = Object.keys(obj);
  return res; // => Type 'string[]' is not assignable to type 'ObjectKeys<T>'.
};

ラッパー関数の返り値の型を (keyof T)[] から 制限を加えた ObjectKeys<T> に変更すると帰り値の res が関数の返り値の指定と異なるとして Type 'string[]' is not assignable to type 'ObjectKeys<T>' が発生した
この場合は返り値を Type Assertion as を使わざるを得なかった

const objectKeys = <T extends { [key: string]: unknown }>(
  obj: T,
): ObjectKeys<T> => {
  const res = Object.keys(obj);
- return res;
+ return res as ObjectKeys<T>;
};

cf. Object.keys() が string[] を返してくる問題を解決する3つの方法

3. ObjectKeys の型定義を上書きする

interface ObjectConstructor {
    // … 略
    /**
     * Returns the names of the enumerable string properties and methods of an object.
     * @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
     */
    keys(o: {}): string[];
}

cf. TypeScript/src/lib/es2015.core.d.ts at e36cd5768aa46ed2ce9487cce768222d8ee05a4d · microsoft/TypeScript · GitHub

Object.keys の型は ObjectConstructor として定義されているようで、これをプロジェクト側で上書きしてしまう方法

/src/types/ObjectKeys.d.ts

type ObjectKeys<T> = T extends { [key: string]: unknown } ? (keyof T)[] : never;

interface ObjectConstructor {
  keys<T>(o: T): ObjectKeys<T>;
}

cf. TypeScript: Improving Object.keys

Object.keys に number や Date オブジェクトを渡すと [] が返ってきたり、配列や文字列を渡すと要素数や文字数の string[] が帰ってくるが、{[key: string]: any} なオブジェクトしか渡してほしくないので、オブジェクトが渡される前提で型定義しました
📝 number, Symbol, Date, Function => [], Array<any>, string, Class => string[]

 

型定義については理解が浅いので若干雰囲気で書いていますが、上記 3 つの方法で Object.keys の返す型をオブジェクトのキーの Union 型の配列にすることができました! 血直接 as keyof tyoeof Object を使う方法はタイポやオブジェクトの指定ミスなど発生しうるのでラッパー関数を使うか ObjectConstructor 内にある Object.keys の定義を上書きしてしまうのが良さそうに思いました。

⚡ Tips Object.keys()string[] 型になるのは JavaScript の仕様のため

JavaScript の Object はキーに string と number を取ることができるが、Object.keys() は暗黙の型変換でキーを全て string として返す仕様があるので TypeScrip では Object.keys()string[] 型を返すものとなっているっぽい

// JavaScript はオブジェクトのキーを string と number で指定できる
const obj = { 0: 'a', '0': 'b', '1': 'c', 1: 'd', 2: 'e' };

// string と number で同じ値は暗黙的に同じキーに変換される
console.log(obj);
// => {0: 'b', 1: 'd', 2: 'e'}

// Object.keys() は暗黙的に文字列に変換する
Object.keys(obj);
// => ['0', '1', '2']

Object.keys() は、object で直接発見された列挙可能なプロパティに対応する文字列を要素とする配列を返します。
cf. Object.keys() - JavaScript | MDN

所感

Object.entries のときも型にハマった記憶があるけど、まさか Object.keys にもハマるとは思ってませんでした。
Object.keys の返す型が string[] は予期しておらず、純粋に JavaScript のオブジェクトはキーに 文字列も数値も使えるが、Object.keysnumber のキーを暗黙の型変換で string にして返す仕様を知らなかったのが原因だったので勉強になりました。

おわり₍ ᐢ. ̫ .ᐢ ₎


[参考]