🔤

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:

MethodResultWhat it counts
"👨‍👩‍👧‍👦".length11UTF-16 code units
[..."👨‍👩‍👧‍👦"].length7Code points
Intl.Segmenter1Grapheme 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:

UnitWhat it isExample: 👍🏽
UTF-16 code unitsWhat .length counts. Includes surrogate pairs.4 units
Code pointsBasic Unicode unit (U+XXXX). What [...str] gives.2 points
Grapheme clustersWhat 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:

FlagCode pointsMeaning
🇯🇵U+1F1EF + U+1F1F5Regional J + P
🇺🇸U+1F1FA + U+1F1F8Regional U + S
🇬🇧U+1F1EC + U+1F1E7Regional 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:

FormCode pointsRepresentation
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 / 2 can 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つの異なる答えが返ります:

方法結果何を数えているか
"👨‍👩‍👧‍👦".length11UTF-16 コードユニット
[..."👨‍👩‍👧‍👦"].length7コードポイント
Intl.Segmenter1書記素クラスタ(見た目の文字)

家族絵文字は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書記素クラスタを表し、クリックで内部構造を確認できます。