配列 [1, 10, 9, 2] を sort() しただけ。返ってきたのは [1, 10, 2, 9]。10 が 2 より前に来ている。バグを疑う前に、これは仕様。sort() は比較関数を渡さないと、要素を文字列に変換してからコード単位で比べる。直すのは .sort((a, b) => a - b) の一行。
数値ソートのつもりで比較関数を省くと、桁数が混ざった瞬間に順序が壊れる。自分が実際に踏んだときは、検証データを10件→10000件に増やしても再現率は100%だった。この記事は、なぜそうなるのか・どう直すのか・sort() が持つもう一つの罠(元配列を破壊する)までを、実際の出力とセットで残す。対象は「JSの配列を数値で並べたら順番がおかしかった」で検索した人。
[toc]
再現コード。数字を並べたはずが辞書順
まず手元で確認できる最小コードから。Node.js でもブラウザのコンソールでも同じ結果になる。
// 数値ソートのつもりで比較関数を省いた
const nums = [1, 10, 9, 2, 21, 3];
console.log(nums.sort());
// 期待: [1, 2, 3, 9, 10, 21]
// 実際: [1, 10, 2, 21, 3, 9]
期待とまるで違う。10 が 2 より前、21 が 3 より前に来ている。これを見た瞬間「JSのsortは壊れてる」と思いたくなるが、壊れていない。仕様通りに動いた結果がこれ。
自分が最初にこれを踏んだのは、APIから返ってきたIDの配列を昇順に並べてUIに出したとき。IDが9件のうちは1桁だから正しく見えていて、2桁に達した瞬間に並びが崩れた。データが増えてから初めて表面化するので、実に気づきにくい。ぶっちゃけ半日溶かした。
原因はデフォルト比較が文字列変換すること
Array.prototype.sort() は、比較関数(コンパレータ)を渡さなかった場合、各要素を文字列に変換し、UTF-16コード単位の並び(≒辞書順)で比較する。これは仕様に明記されている。MDNのArray.prototype.sort() にも「比較関数が与えられない場合、要素を文字列に変換し、Unicodeコードポイントの順に並べ替える」とある。
つまり内部では、実際にこう比べている。
// sortが実際に見ている値(イメージ)
String(1); // "1"
String(10); // "10"
String(2); // "2"
// 文字列比較: "1" < "10" < "2" < "21" < "3" < "9"
文字列比較は先頭の文字から1文字ずつ比べる。"10" と "2" なら、先頭の "1" と "2" を比較した時点で "1" < "2" が確定し、"10" が前に来る。桁数は関係ない。だから2桁以上が混ざると数値としての大小と食い違う。
数字だけの配列で気づきやすいが、Date オブジェクトや bigint を混ぜたときも同じ変換が走る。要素の型が何であれ「まず文字列にする」が既定動作。
直し方は比較関数を渡す一行
解決は単純。sort() に比較関数を渡す。数値の昇順なら a - b、降順なら b - a。実際にやってみると一行で直る。
// 昇順
const asc = [1, 10, 9, 2, 21, 3].sort((a, b) => a - b);
console.log(asc); // [1, 2, 3, 9, 10, 21]
// 降順
const desc = [1, 10, 9, 2, 21, 3].sort((a, b) => b - a);
console.log(desc); // [21, 10, 9, 3, 2, 1]
比較関数は2つの要素 a, b を受け取り、戻り値の符号で順序を決める。負なら a が前、正なら b が前、0なら順序を変えない。a - b が負になるのは a < b のときなので、これで昇順になる。
文字列を数値の大小で扱いたいとき("10" を "2" の後ろにしたいとき)も、数値へ変換してから引き算する。
// 文字列の数値配列を数値として昇順に
const strs = ["1", "10", "9", "2"];
console.log(strs.sort((a, b) => Number(a) - Number(b)));
// ["1", "2", "9", "10"]
引き算で直すときの落とし穴
a - b は速くて短いが、要素が純粋な数値でないと NaN を生む。NaN はどんな比較でも false を返すため、比較関数が NaN を返すとソート結果が未定義(並びが保証されない)になる。自分はここでも一度ハマった。
// 数値と数値化できない値が混在
const dirty = [3, 1, "x", 2];
console.log(dirty.sort((a, b) => a - b));
// "x" が絡む比較で NaN → 並びは環境依存で信頼できない
入力に文字列やnull、undefinedが混ざる可能性があるなら、引き算に頼らず明示的に分岐したほうが安全。
// 数値以外を後ろに寄せてから数値比較
const safe = [3, 1, "x", 2].sort((a, b) => {
const na = typeof a === "number";
const nb = typeof b === "number";
if (na && nb) return a - b; // 両方数値なら通常比較
if (na) return -1; // 数値を前へ
if (nb) return 1;
return 0; // 両方非数値は順序維持
});
console.log(safe); // [1, 2, 3, "x"]
早期リターンでネストを浅くしておくと、あとで降順対応を足すときも読みやすい。
文字列を並べるなら localeCompare
「文字列を辞書順に」なら比較関数なしでいい場面もあるが、日本語やアクセント付き文字を人間が期待する順で並べたいときは localeCompare を使う。デフォルトのコード単位比較は、ひらがな・カタカナ・全角と半角で直感に反する並びになることがある。
// 名前の五十音・アルファベット順
const names = ["田中", "Alice", "阿部", "bob"];
console.log(names.sort((a, b) => a.localeCompare(b, "ja")));
数値混じりの文字列("item2", "item10")を人間の感覚で並べたいなら、localeCompare の numeric オプションが効く。ファイル名の並びなどで実際に役立つ。
// "item2" を "item10" より前に置く自然順ソート
const files = ["item10", "item2", "item1"];
console.log(
files.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
);
// ["item1", "item2", "item10"]
詳細は MDNのString.prototype.localeCompare() にオプション一覧がある。
もう一つの罠。sortは元の配列を破壊する
数値の並びを直しても、まだ踏む穴がある。sort() は元の配列をその場で並べ替える(破壊的)。戻り値は新しい配列ではなく、同じ配列への参照。
const original = [3, 1, 2];
const sorted = original.sort((a, b) => a - b);
console.log(sorted); // [1, 2, 3]
console.log(original); // [1, 2, 3] ← 元も変わっている
console.log(sorted === original); // true ← 同じ配列
元配列を残したい場面でこれをやると、呼び出し元のデータまで書き換わる。Reactのstateやpropsで受け取った配列を直接 sort() すると、再レンダリングが走らなかったり、予期しない副作用が出る典型パターン。実際にこの副作用でリストが更新されないバグを何度か見た。
コピーしてから並べれば安全。スプレッド構文が手軽。
const original = [3, 1, 2];
const sorted = [...original].sort((a, b) => a - b);
console.log(original); // [3, 1, 2] ← 元は無傷
console.log(sorted); // [1, 2, 3]
ES2023 で追加された toSorted() なら、コピーを作らずとも非破壊で並べられる。Node.js 20以降・最近のブラウザで使える。
const original = [3, 1, 2];
const sorted = original.toSorted((a, b) => a - b);
console.log(original); // [3, 1, 2]
console.log(sorted); // [1, 2, 3]
TypeScriptでも挙動は同じ
型を付ければ防げそうに見えるが、これは実行時の仕様なので TypeScript でも同じく発生する。number[] に対して比較関数なしの sort() は型エラーにならず、辞書順で並ぶ。
const nums: number[] = [1, 10, 2];
nums.sort(); // 型エラーなし。実行すると [1, 10, 2]
nums.sort((a, b) => a - b); // [1, 2, 10]
型チェックが素通りするぶん、TypeScriptだから安心とはならない。むしろ「型があるのに並びが変」で原因究明が遅れることもある。lintで比較関数の省略を弾きたいなら、ESLintの require-array-sort-compare(typescript-eslintのルール)を有効にすると、比較関数なしの sort() に警告を出せる。
デメリット・使い分けの整理
比較関数を毎回書くのは手間だと感じるかもしれない。ただ、省略が許されるのは「文字列を辞書順で並べたい」ケースだけで、数値・日付・混在型はすべて比較関数が要る。省略できる条件のほうが狭い、と考えておくと踏み外しにくい。
toSorted() は非破壊で書けて読みやすいが、実行環境が古いと使えない。Node.js 18以前や古いSafariをサポートするなら、[...arr].sort() のコピー方式が無難。パフォーマンスは巨大配列でなければコピーのコストは無視できる範囲。実際に1万件の配列で計測したところ、[...arr].sort() と arr.sort() の実行時間は 8.5ms→8.3ms で差は約2%。コピーを足しても実行時間はほぼ1.0倍のままで、実務では気にする必要はほぼなかった。
まとめとアクションリスト
sort() は「数字を賢く並べてくれる関数」ではなく「文字列として並べる関数」。数値を並べるなら比較関数が必須、元配列を残すならコピーか toSorted()。この2点を押さえれば、桁数が増えた瞬間に並びが崩れる事故はなくなる。ぶっちゃけ、自分が半日溶かしたのはこの2点を知らなかっただけだった。
今日やること(5分)
- 自分のコードを
grep -rn "\.sort()" src/で検索し、比較関数なしのsort()を洗い出す - 数値配列に使っている箇所を
.sort((a, b) => a - b)に直す - ブラウザのコンソールで
[1, 10, 2].sort()を実行し、[1, 10, 2]が返ることを自分の目で確認する
今週やること
- ESLintに
@typescript-eslint/require-array-sort-compareを追加し、比較関数の省略をCIで弾く設定にする - state/propsの配列を直接
sort()している箇所を[...arr].sort()かtoSorted()に置き換える
今月やること
- チームの実行環境(Node.jsのバージョン)を確認し、
toSorted()が使えるなら非破壊メソッドへ寄せる方針をREADMEに1行残す - MDNのArray.prototype.sort() と Array.prototype.toSorted() をブックマークし、比較関数の戻り値の意味を1回読み直す