文字を正しく数える — 絵文字と結合文字

length が見た目の文字数と合わない理由を、サロゲートペアと結合文字から説明します。スプレッド構文と Intl.Segmenter による数え方の違い、正規化と並べ替えまで実行して確かめます。

実行して検証済み処理系Node 22.22.3対応ES2015難易度●●●○○目安11更新

第1回length を使いました。あれは、いつでも正しいわけではありません。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
3 4
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
1 2

見た目はどちらも3文字です。なのに、絵文字が入ると4になりました。

length が数えているもの

JavaScript の文字列はUTF-16という形式で持たれています。 length が返すのは、その符号単位(code unit)の個数です。

多くの文字は符号単位1つで表せます。ですが、表しきれない文字があります。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
12354 128578 🙂
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
4 3 [ 'あ', '🙂', 'い' ]

絵文字は符号単位2つの組で表されます。これをサロゲートペアと呼びます。

つまり length は嘘をついていません。 「文字の数」を数えているつもりが無いだけで、符号単位の数という約束を守っています。 数えたいものが違う、というだけの話です。

1つの文字としての番号を見る

codePointAt() は、符号位置(code point)を返します。こちらが「文字そのものの番号」です。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
8 5
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
4 2

655350xFFFF)を超える番号の文字が、サロゲートペアになります。

符号位置ごとに分ける

スプレッド構文 [...]Array.from() は、符号位置ごとに分けてくれます。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
3 1 1
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
[ '今日', 'は', '良い', '天気', 'です' ]

見た目と合いました。 多くの場面では、これで十分です。

それでも合わないものがある

家族の絵文字を数えてみます。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
が false 1 2
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
true

見た目は1文字なのに、符号位置でも5あります。 これは3人の絵文字を見えないつなぎ記号で連結したものだからです。

肌の色を指定した絵文字でも同じことが起きます。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
ア A1
example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
[ 'Apple', 'Date', 'banana', 'cherry' ]

「親指」と「肌の色」の2つが組み合わさっています。

見た目の1文字=書記素

人が「1文字」と感じる単位を書記素(grapheme cluster)と呼びます。 これを数えるのが Intl.Segmenter です。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
[ 'Apple', 'banana', 'cherry', 'Date' ]
javascript
const text = 'あ🙂い';
const reversed = text.split('').reverse().join('');

console.log(reversed.length);
console.log(reversed === 'い🙂あ');
4 false

ようやく見た目と一致しました。

3つの数え方

数え方 書き方 '👨‍👩‍👧'
符号単位 text.length 8
符号位置 [...text].length 5
書記素 Intl.Segmenter 1

どれが正しいということはありません。 目的で選びます。

  • 保存できる長さの上限を見る → length(データの大きさに近い)
  • 1文字ずつ処理する → [...text]
  • 入力欄の「あと何文字」を出す → Intl.Segmenter

Intl.Segmenter は 2024 年に主要なブラウザで揃いました。古い環境も相手にするなら対応状況を確認してください。

単語で区切ることもできる

granularity を変えると、単語の単位で分けられます。

example.js
出力書き換えて実行できます
Ctrl+Enter でも実行JS · UTF-8
い🙂あ true
javascript
const text = 'あ🙂い';

console.log(text.slice(0, 2).length);
console.log(text.slice(0, 2) === 'あ🙂');
2 false

日本語には単語の区切りに空白がありません。 それでも分けられます。

見た目が同じでも、等しくない

ここからは別の問題です。2通りの書き方があります。

画面には同じ が出ているのに、等しくありません。 片方は「が」1文字、もう片方は「か」+「濁点」の2文字です。

macOS のファイル名やコピーしてきた文字列で、実際に起きます。

normalize() で揃える

比べる前に、どちらかの形へ正規化します。

利用者が入力した文字列を比べるときは、正規化してから比べてください。

NFKC を使うと、半角カタカナや全角英数も揃います。

検索の照合に向きますが、元の表記は失われます。保存する値には使わないでください。

並べ替えも辞書順ではない

sort()符号位置の順に並べます。人が期待する順とは違います。

大文字が先にまとまってしまいました。 符号位置では大文字のほうが小さいからです。

localeCompare() を使うと、辞書の順に近づきます。

ひらがな同士のように、既定の並べ替えでも同じ結果になる場合はあります。違いが出るかどうかは中身によります。

よくある間違い

split('') で1文字ずつに分ける

split('')符号単位で分けます。 サロゲートペアが2つに割れてから逆順になるので、絵文字が壊れます。

スプレッド構文なら符号位置ごとに分かれるので、壊れません。

ただし、これでも結合文字は壊れます。家族の絵文字を逆順にすれば分解されます。 確実に扱うなら Intl.Segmenter で分けてください。

途中で切ると壊れる

slice() も符号単位で切ります。絵文字の途中で切れました。 文字数で切り詰めるなら、先に [...text]Intl.Segmenter で分けてから取ってください。

まとめ

  • length符号単位の数。見た目の文字数ではない
  • 絵文字は符号単位2つ(サロゲートペア
  • [...text] は符号位置ごと。多くの場面ではこれで足りる
  • 家族や肌の色の絵文字は、符号位置でも1にならない
  • 見た目の1文字=書記素。数えるなら Intl.Segmenter
  • 同じに見えて等しくない文字がある。比べる前に normalize()
  • sort() は符号位置の順。辞書順に近づけるなら localeCompare()

この連載はここまで

文字列を作り、切り出し、探し、正しく数えるところまで来ました。 ここから先は、必要になったときに引ける形でまとめてあります。

この記事の根拠

  1. String.prototype.length — ECMAScript® 2026 Language Specificationtc39.es
  2. Intl.Segmenter — ECMAScript® 2026 Internationalization API Specificationtc39.es
  3. Unicode Standard Annex #29: Unicode Text Segmentationunicode.org

掲載しているコード例は、公開前に読み手が押したときに動くのと同じもので実行して出力を突き合わせています。結果はリポジトリに残しています。

このページの原文(Markdown)