カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Object.keys() が返す配列の順序における数値キーの昇順には上限がある

はじめに

こんにちは。昨年の10月にカミナシに入社したソフトウェアエンジニアの tokuse です。 気が付けば入社してから既に半年以上経っており、光陰矢の如しで驚愕しています!

カミナシではフロントエンドを TypeScript で開発しています。そんな中、先日 Object.keys() の仕様に起因する不具合が発生し、その際に Object.keys() が返す配列の順序に関する仕様について詳しく知りました。当稿ではその仕様について説明します(ECMAScript 最新前提です)。

問題となった処理

まず、問題となった処理をサンプルコードで紹介します。次のコードは、オブジェクトの数値キーのうち最大値を取得しようとした処理です。

type UserId = number;

type User = {
  id: UserId;
  name: string;
};

const userDict: Record<UserId, User> = {};
userDict[5] = { id: 5, name: "五郎" };
userDict[4] = { id: 4, name: "四郎" };
userDict[3] = { id: 3, name: "三郎" };
userDict[2] = { id: 2, name: "二郎" };
userDict[1] = { id: 1, name: "一郎" };

// 最大の UserId を取得する(問題となった処理)
const keys = Object.keys(userDict);
const maxUserId = Number(keys[keys.length - 1]);

// maxUserId を使用する処理
console.log(maxUserId); // -> 5 が出力される

問題となった処理では、Object.keys() が返す配列の末尾を最大値と見做しており、Object.keys() が返す配列の順序が数値キーの昇順であることを期待しています。上記のコードを実行すると、コンソールには 5 が出力されますし、Object.keys() の結果を出力すると、 ['1', '2', '3', '4', '5'] が出力されるのでコードは正しいように見えます。しかし、次のコードのように数値キーが一定の数値を超えると期待する結果が得られなくなります。

type UserId = number;

type User = {
  id: UserId;
  name: string;
};

const userDict: Record<UserId, User> = {};
userDict[4294967297] = { id: 4294967297, name: "五郎" };
userDict[4294967296] = { id: 4294967296, name: "四郎" };
userDict[4294967295] = { id: 4294967295, name: "三郎" };
userDict[4294967294] = { id: 4294967294, name: "二郎" };
userDict[4294967293] = { id: 4294967293, name: "一郎" };

// 最大の UserId を取得する(問題となった処理)
const keys = Object.keys(userDict);
const maxUserId = Number(keys[keys.length - 1]);

// maxUserId を使用する処理
console.log(maxUserId); // -> 4294967295 が出力される

上記のコードを実行すると、コンソールには 4294967295 が出力され、Object.keys() の結果を出力すると ['4294967293', '4294967294', '4294967297', '4294967296', '4294967295'] が出力されます。実行結果から分かる通り Object.keys() が返す配列の順序は数値キーの昇順とは限りません。

Object.keys() の仕様

Object.keys() が返す配列の順序は数値キーの昇順の限りではないことが分かりました。もう少し踏み込んで具体的にはどのような順序であると定義されているかを MDN Web Docs を参照しながら説明します。

Object.keys() の解説では次のように記載されており、for…in ループで指定された順序と同じことが分かります。

Object.keys() は、 object で直接見つかった列挙可能なプロパティに対応する、文字列を要素とする配列を返します。これは for...in ループによる反復処理と同じですが、 for...in ループではプロトタイプチェーン内のプロパティも同様に反復処理します。 Object.keys() が返す配列の順序は、 for...in ループで指定された順序と同じです。 (“Object.keys() - JavaScript | MDN”)

では、for…in ループで指定された順序とはなんでしょうか。for…in の解説では次のように記載されています。

現代の ECMAScript の仕様では、走査順序は明確に定義されており、 実装同士の間で一貫しています。プロトタイプチェーンのそれぞれの成分内では、非負の整数値(配列の添字となるもの)はすべて値の昇順で最初に走査され、次に文字列のキーがプロパティの作成時系列で昇順に走査されます。 (“for...in - JavaScript | MDN”)

上記の引用から for…in ループの指定された順序とは次に示す順序であることが分かります。

  1. 非負の整数値(配列の添字となるもの)の昇順
  2. 文字列キーのプロパティの作成時系列の昇順

Array: length によると配列の最大要素数は 232 - 1(=4294967295) であり、一般的に配列の添字は配列の要素数 - 1 であるため、配列の添字となるものは 0 から 232 -2(=4294967294) までとなります。

Object.keys() が返す配列の順序の仕様が分かったところで、問題となったコードでの順序を改めて確認してみます。

type UserId = number;

type User = {
  id: UserId;
  name: string;
};

const userDict: Record<UserId, User> = {};
userDict[4294967297] = { id: 4294967297, name: "五郎" };
userDict[4294967296] = { id: 4294967296, name: "四郎" };
userDict[4294967295] = { id: 4294967295, name: "三郎" };
userDict[4294967294] = { id: 4294967294, name: "二郎" };
userDict[4294967293] = { id: 4294967293, name: "一郎" };

const keys = Object.keys(userDict);
for (let i = 0; i < keys.length; i++) {
  console.log(keys[i]);
  // 以下の順番で出力される
  // -> 4294967293
  // -> 4294967294
  // -> 4294967297
  // -> 4294967296
  // -> 4294967295
}

// 前述した通り、もちろん for...in ループでも同様の結果となる
for (const key in userDict) {
  console.log(key)
}

この実行結果から、まずプロパティのキーの 4294967294 までは数値の昇順となり、次に 4294967295 以上は作成時系列の昇順になっていることが分かります。4294967295 以上の数値は文字列キーとして扱われるようです。

まとめ

Object.keys() が返す配列の順序は次のとおりです。

  1. 0 から 232 -2(=4294967294) までの数値の昇順
  2. 文字列キーのプロパティの作成時系列の昇順

上記の仕様を考慮すると、問題となった処理でやりたかったオブジェクトの数値キーの最大値を取得する処理は、数値キーが 4294967294 までであれば、数値の昇順であることが保証されているため、Object.keys() の結果の末尾で正しく取得できます。しかし、数値キーが 4294967294 を超える可能性がある場合には、4294967294 までは数値の昇順になりますが 4294967294 を超えたキーはプロパティの作成時系列順となるため、数値キーの最大値を取得したいときは、素直に Math.max() を使用しましょう。

余談

余談ですが、Object.keys() の仕様を調べていた時は MDN Web Docs での説明を探しきれず、ECMAScript を読むことで仕様を理解しました。ECMAScript の読み方はいまだによく分かっていないところが多いため、どうやって説明するか悩んでいたところ、MDN の AI Help を使ってMDN Web Docs での説明を見つけることができたため、当稿では MDN Web Docs を用いて説明をしてみました!

おわりに

232 のような特定の大きな数値が閾値となって配列の順序が変わることを不具合を通して経験することができました。関数の仕様を正しく理解する大事さを改めて実感しました。

拙い文章ですが、最後までお読みいただきありがとうございました。当稿が何かの参考になれば幸いです!

最後に宣伝です📣

カミナシでは絶賛採用中です!一緒に最高のサービスを作っていく仲間を募集しています!