Characters Are a Lie: Understanding Grapheme Clusters「1文字」は嘘: 書記素クラスタを理解する
Why string.length gives wrong answers, what grapheme clusters really are, and how Intl.Segmenter fixes everything.string.length が嘘をつく理由、書記素クラスタの正体、そして Intl.Segmenter による解決。
The "One Character" Illusion
How many characters are in this string: 👨👩👧👦?
Most people answer “one” — it looks like a single family emoji. But ask JavaScript, and you get three different answers:
| Method | Result | What it counts |
|---|---|---|
| "👨👩👧👦".length | 11 | UTF-16 code units |
| [..."👨👩👧👦"].length | 7 | Code points |
| Intl.Segmenter | 1 | Grapheme clusters (visual characters) |
The family emoji is composed of 7 code points: four person/child emoji joined by three Zero Width Joiners (ZWJ). In UTF-16, each emoji above U+FFFF takes two code units (a surrogate pair), giving .length a count of 11.
Three Different Counts
Understanding the three counts is fundamental to working with Unicode correctly:
| Unit | What it is | Example: 👍🏽 |
|---|---|---|
| UTF-16 code units | What .length counts. Includes surrogate pairs. | 4 units |
| Code points | Basic Unicode unit (U+XXXX). What [...str] gives. | 2 points |
| Grapheme clusters | What humans see as "one character". | 1 cluster |
👍🏽 is two code points: 👍 (U+1F44D) + skin tone modifier 🏽 (U+1F3FD). Each is above U+FFFF, so each takes two UTF-16 code units. But it renders as one grapheme cluster.
Flag Emoji: Regional Indicator Math
Flag emoji are pairs of Regional Indicator symbols forming one grapheme cluster:
| Flag | Code points | Meaning |
|---|---|---|
| 🇯🇵 | U+1F1EF + U+1F1F5 | Regional J + P |
| 🇺🇸 | U+1F1FA + U+1F1F8 | Regional U + S |
| 🇬🇧 | U+1F1EC + U+1F1E7 | Regional G + B |
Three flags, but "🇯🇵🇺🇸🇬🇧".length returns 12. Only Intl.Segmenter correctly identifies 3 grapheme clusters.
When Normalization Changes the Count
The string café can be encoded two ways in Unicode:
| Form | Code points | Representation |
|---|---|---|
| NFC (composed) | c a f é (4 CPs) | é = U+00E9 |
| NFD (decomposed) | c a f e ◌́ (5 CPs) | e + combining acute = U+0065 U+0301 |
Both look identical — 4 grapheme clusters. But code point count differs (4 vs 5), and so does .length.
Practical Consequences
Getting character counting wrong causes real bugs:
- String truncation: Cutting at
.length / 2can split a surrogate pair, producing U+FFFD. - Cursor movement: Should skip the entire grapheme cluster, not individual code points.
- Input validation: “Max 10 characters” should count grapheme clusters, not
.length. - String reversal:
[...str].reverse().join("")breaks ZWJ sequences and flag emoji.
The Solution: Intl.Segmenter
Intl.Segmenter (available in all modern browsers since 2024) correctly segments text by grapheme cluster boundaries:
const segmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme"
});
const text = "👨👩👧👦🇯🇵café";
const segments = [...segmenter.segment(text)];
console.log(segments.length); // 6 (correct!)
// Compare:
console.log(text.length); // 18 (UTF-16 units)
console.log([...text].length); // 12 (code points)This tool uses Intl.Segmenter internally. Each cell in the grid represents one grapheme cluster — click any cell to see its internal structure.
「1文字」という幻想
この文字列は何文字でしょうか: 👨👩👧👦
多くの人は「1文字」と答えます。しかし JavaScript に聞くと、数え方によって3つの異なる答えが返ります:
| 方法 | 結果 | 何を数えているか |
|---|---|---|
| "👨👩👧👦".length | 11 | UTF-16 コードユニット |
| [..."👨👩👧👦"].length | 7 | コードポイント |
| Intl.Segmenter | 1 | 書記素クラスタ(見た目の文字) |
家族絵文字は7つのコードポイントで構成: 4つの人物絵文字を3つのゼロ幅接合子(ZWJ)で結合。UTF-16 では U+FFFF 超の絵文字は各2ユニット(サロゲートペア)のため、.length は 11。
3つの異なるカウント
3つのカウントの違いは Unicode を正しく扱う基本です:
| 単位 | 意味 | 例: 👍🏽 |
|---|---|---|
| UTF-16 コードユニット | .length が返す値。サロゲートペアを含む。 | 4 ユニット |
| コードポイント | Unicode の基本単位 (U+XXXX)。[...str] で取得。 | 2 ポイント |
| 書記素クラスタ | 人間が「1文字」と認識する単位。 | 1 クラスタ |
👍🏽 は2コードポイント: 👍 (U+1F44D) + 肌色修飾子 🏽 (U+1F3FD)。各 U+FFFF 超で UTF-16 では各2ユニット。しかし見た目は1文字 = 1書記素クラスタ。
国旗絵文字: 地域インジケータの組み合わせ
国旗絵文字は地域インジケータ記号のペアで1書記素クラスタを形成:
| 国旗 | コードポイント | 意味 |
|---|---|---|
| 🇯🇵 | U+1F1EF + U+1F1F5 | 地域 J + P |
| 🇺🇸 | U+1F1FA + U+1F1F8 | 地域 U + S |
| 🇬🇧 | U+1F1EC + U+1F1E7 | 地域 G + B |
3つの国旗ですが "🇯🇵🇺🇸🇬🇧".length は 12。Intl.Segmenter だけが正しく3書記素クラスタを識別。
正規化でカウントが変わる
café は Unicode で2通りの表現が可能:
| 形式 | コードポイント | 表現 |
|---|---|---|
| NFC(合成) | c a f é (4 CP) | é = U+00E9 |
| NFD(分解) | c a f e ◌́ (5 CP) | e + 結合アキュート = U+0065 U+0301 |
画面上は同一で4書記素クラスタ。しかしコードポイント数(4 vs 5)と .length は異なる。
実用上の影響
文字カウントの間違いは実際のバグを引き起こします:
- 文字列の切り詰め:
.length / 2で切るとサロゲートペアが分断され U+FFFD に。 - カーソル移動: 「1文字」移動は書記素クラスタ全体をスキップすべき。
- 入力バリデーション: 「最大10文字」は書記素クラスタで数えるべき。
- 文字列反転:
[...str].reverse().join("")は ZWJ シーケンスや国旗絵文字を壊す。
解決策: Intl.Segmenter
Intl.Segmenter(2024年以降の全モダンブラウザで利用可能)は書記素クラスタ境界で正しく分割:
const segmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme"
});
const text = "👨👩👧👦🇯🇵café";
const segments = [...segmenter.segment(text)];
console.log(segments.length); // 6(正解!)
// 比較:
console.log(text.length); // 18(UTF-16 ユニット)
console.log([...text].length); // 12(コードポイント)このツールは Intl.Segmenter を内部で使用。グリッドの各セルが1書記素クラスタを表し、クリックで内部構造を確認できます。