Agent Grow Tech Notes
🔀

いろんな文字種が交じった文字列をソートしたい?それならnormalizeにお任せ!

に公開

この記事は Agent Grow Advent Calendar 2025 の記事です。

こんな開発要件があったよ

  • 与えられた文字列たちをソートする。
  • ソート対象となる文字列は、半角英数字および全角英数字、丸数字(①や②)で構成されている。
  • 数字は半角、全角を問わずにソートする。丸数字もソート対象にする。
    • ①、2(全角)、3(半角)、4(全角)が与えられた場合、①→2→3→4の順にソートする。
  • 連続する数字の大小は気にしない。11と9が与えられた場合、11→9の順にソートする。
  • 英字も半角、全角、小文字、大文字を問わずにソートする。
    • AWL(全角)、apple(半角)、BEAR(半角)、beer(半角)が与えられた場合、apple→AWL→BEAR→beerの順にソートする。

さて、どんな感じでアプローチしますか。
toLowerCase()は英字の小文字、大文字は揃えられるけれど、数値の全角/半角には対応できない。
localeCompare()だと、丸数字の比較までは対応していない。
replace()で泥臭く変換テーブル作る?
charCodeAt()fromCharCode()を使い、文字コードで演算して全角半角変換する?[1]

色々方法はあるけれど、今回はnormalizeで解決!

結論:こんな感じ!

const normalizedCompare = (a, b) => {
  return a.normalize("NFKD").localeCompare(b.normalize("NFKD"))
}

const original = [
  "apple5",
  "BEAR9",
  "AWL①",
  "apple4",
  "beer",
  "BEAR11",
  "AWL2"]

const normalizedSorted = ([...original]).sort(normalizedCompare)
console.log(normalizedSorted)
// ["apple4", "apple5", "AWL①", "AWL2", "BEAR11", "BEAR9", "beer"] が出力される

ここから解説

normalizeって何?

詳しくはMDN参照。
指定した文字のUnicode正規化形式を返してくれるというもの。
(正規化ってDBの話だけじゃないのね...)

引数はNFC NFD NFKC NFKDの4種類。

引数 評価方法 出力方法
NFC 正準等価性 分割して結合
NFD 正準等価性 分割のみ
NFKC 互換等価性 分割して統合
NFKD 互換等価性 分割のみ

...聞いたことのない言葉の嵐。
これだけだと、何が起こるのかさっぱり分かりませんねぇ。
どういう挙動をするのか、ガチャガチャ試しまくるのが一番ですね。

動かしてみた

normalizeの挙動を確認するため、テスト用の関数を組みました。

const normalizeTest = (str) => {
  ["NFC", "NFD", "NFKC", "NFKD"].forEach((form) => {
    const normalized = str.normalize(form);
    const codeValue = [...normalized].map(v =>
      `\\u${v.codePointAt(0).toString(16).padStart(4, '0')}`).join('')
    console.log(form, normalized, codeValue);
  })
}

あとは様々な文字列を突っ込んでみるだけ。
元の文字列から変わったものは太字にしています。

英数字系

変換結果
試した文字 NFC NFD NFKC NFKD メモ
1
(半角数字)
1
(\u0031)
1
(\u0031)
1
(\u0031)
1
(\u0031)
変化なし

(全角数字)

(\uff11)

(\uff11)
1
(\u0031)
1
(\u0031)
互換等価性評価(NFKC,NFKD)で半角数字に変換される

(全角の丸数字)

(\u2460)

(\u2460)
1
(\u0031)
1
(\u0031)
互換等価性評価(NFKC,NFKD)で半角数字に変換される

(括弧付きの全角数字)

(\u2474)

(\u2474)
(1)
(\u0028
\u0031
\u0029)
(1)
(\u0028
\u0031
\u0029)
互換等価性評価(NFKC,NFKD)で半角の括弧と半角数字に変換される
a
(半角小文字の英字)
a
(\u0061)
a
(\u0061)
a
(\u0061)
a
(\u0061)
変化なし
B
(半角大文字の英字)
B
(\u0042)
B
(\u0042)
B
(\u0042)
B
(\u0042)
変化なし

(全角小文字の英字)

(\uff43)

(\uff43)
c
(\u0063)
c
(\u0063)
互換等価性評価(NFKC,NFKD)で半角英字に変換される

(全角大文字の英字)

(\uff24)

(\uff24)
D
(\u0044)
D
(\u0044)
互換等価性評価(NFKC,NFKD)で半角英字に変換される

ここまでは、冒頭のソート関数で使ったような文字種です。
文字の見た目は違うけれど、意味としては同じだね』というものは
互換等価性評価(NFKC,NFKD)で変換されるようですね。

まだまだいってみましょう💪

全角カタカナ

変換結果
試した文字 NFC NFD NFKC NFKD メモ

(全角カタカナ)

(\u30ab)

(\u30ab)

(\u30ab)

(\u30ab)
変化なし

(全角カタカナ、濁点付き)

(\u30ac)
ガ
(\u30ab
\u3099)

(\u30ac)
ガ
(\u30ab
\u3099)
正準等価性評価でも判定可。濁点の付かない全角カタカナ(カ)と濁点記号に分割可能

(全角カタカナ、半濁点付き)

(\u30d1)
パ
(\u30cf
\u309a)

(\u30d1)
パ
(\u30cf
\u309a)
正準等価性評価でも判定可。半濁点の付かない全角カタカナ(ハ)と半濁点記号に分割可能

お、ついに出力方法による違いが出てきましたね!
ここでいう濁点記号や半濁点記号のコード(\u3099\u309a)は、独立して1文字として存在するコードではなく
直前に出てくる文字とくっついて、見た目上の1文字を表現できるコードになります。

元々1文字の『パ(\u30d1)』と
半濁点の付かないカタカナ()に、半濁点記号をつなげて作られた『パ(\u30cf\u309a)』は
見た目は同じ文字なのに、バイトコードも異なりますし
JavaScript上で比較(===)した場合には、異なる文字として評価されます。[2]

正準等価性というのは、ここで出てきた1バイトの「パ(\u30d1)」と2バイトの「パ(\u30cf\u309a)」のように
意味としてだけでなく、見た目が同じ文字に見えるね』を評価するようですね。

正準等価性は、互換等価性よりも縛りが厳しいため

  • 正準等価性があるものは、すべて互換等価性もある
  • 逆に、互換等価性があっても正準等価性があるとは限らない

と言えます。
数学記号で書くなら『正準等価性⊂互換等価性』といったところでしょうか。

const oneCharPa = '\u30d1'
console.log(oneCharPa, oneCharPa.length)
// => "パ" 1 が出力される

const twoCharPa = '\u30cf\u309a'
console.log(twoCharPa, twoCharPa.length)
// => "パ" 2 が出力される

console.log(oneCharPa === twoCharPa)
// => false が出力される

まだまだいきましょう。

半角カタカナ

変換結果
試した文字 NFC NFD NFKC NFKD メモ

(半角カタカナ)

(\uff76)

(\uff76)

(\u30ab)

(\u30ab)
互換等価性評価(NFKC,NFKD)で全角カタカナに変換される
ガ
(半角カタカナ)
ガ
(\uff76
\uff9e)
ガ
(\uff76
\uff9e)

(\u30ac)
ガ
(\u30ab
\u3099)
互換等価性評価(NFKC,NFKD)で全角カタカナに変換される。濁点記号は分割可能
パ
(半角カタカナ)
パ
(\uff8a
\uff9f)
パ
(\uff8a
\uff9f)

(\u30d1)
パ
(\u30cf
\u309a)
互換等価性評価(NFKC,NFKD)で全角カタカナに変換される。半濁点記号は分割可能

いろいろ分かってきました。
カタカナの正規形は半角ではなく、全角のようです。
半角カタカナの濁点、半濁点は、単独で1文字として存在する文字コード(\uff9e\uff9f)がありますが、全角カタカナの時のように、「前の文字と連結して、見た目上1文字として表現するための濁点・半濁点」のコードは存在しないようです。

変化球もいってみましょう⚾️

記号・特殊文字系

変換結果
試した文字 NFC NFD NFKC NFKD メモ

(大文字のローマ数字、全角)

(\u2162)

(\u2162)
III
(\u0049
\u0049
\u0049)
III
(\u0049
\u0049
\u0049)
互換等価性評価(NFKC,NFKD)で半角英字に変換される

(小文字のローマ数字、全角)

(\u2173)

(\u2173)
iv
(\u0069
\u0076)
iv
(\u0069
\u0076)
互換等価性評価(NFKC,NFKD)で半角英字に変換される

(\u334d)

(\u334d)
メートル
(\u30e1
\u30fc
\u30c8
\u30eb
)
メートル
(\u30e1
\u30fc
\u30c8
\u30eb
)
互換等価性評価(NFKC,NFKD)で全角カタカナに変換される
é é
(\u00e9)

(\u0065
\u0301)
é
(\u00e9)

(\u0065
\u0301)
フランス語などにあるアクセント記号付きのe。caféとかで見たことがあるはず。
eとアクセント記号は分割可能

ローマ数字は数字ではなく、半角英字に変換されてしまうのか...
ドラ◯エなどのナンバリングタイトルをソートするには、normalize以外の何かと掛け合わせる必要があるようです。

㍍のように、単位を表す文言が1文字にまとまっているものも、中に書かれているカタカナ通りにソートさせることができそうですね!

まとめ

結論のコードで使っていた、normalize("NFKD")
互換等価性による評価で、幅広い文字種を対象としながら
複数の文字コードで合成されるような文字を分解しているんですね。[3]

途中からはnormalizeの使い方というか、unicodeの勉強になってしまった感もありますが
いろいろな文字表現があり、normalizeはそれらを整えるお手伝いをしてくれるということで
締めたいと思います!

脚注
  1. 32を足したり引いたりする古典的な変換方法 ↩︎

  2. 文字を打ち込んで検索する方法では検知できないので厄介。。 ↩︎

  3. 今回の要件ではカタカナの並べ替えは入っていないので、NFKCでもNFKDでも同じ結果となりそうです ↩︎

Agent Grow Tech Notes
Agent Grow Tech Notes

Discussion