いろんな文字種が交じった文字列をソートしたい?それなら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) |
変化なし |
| 1 (全角数字) |
1 (\uff11) |
1 (\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) |
変化なし |
| c (全角小文字の英字) |
c (\uff43) |
c (\uff43) |
c (\u0063) |
c (\u0063) |
互換等価性評価(NFKC,NFKD)で半角英字に変換される |
| D (全角大文字の英字) |
D (\uff24) |
D (\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はそれらを整えるお手伝いをしてくれるということで
締めたいと思います!
Discussion