🧩

Surrogate Pairs: Why JavaScript Strings Break on Emojiサロゲートペア: なぜJavaScriptは絵文字で壊れるのか

How UTF-16 surrogate pairs work, why they affect JavaScript/Java/C#, and how to handle them correctly.UTF-16 サロゲートペアの仕組み。JavaScript/Java/C# で問題になる理由と正しい対処法。

The BMP Boundary: U+FFFF

When Unicode was first designed in the late 1980s, the consortium believed 65,536 code points (16 bits) would be enough for every character in every language. This original range — U+0000 to U+FFFF — is called the Basic Multilingual Plane (BMP).

They were wrong. As more scripts, historic characters, and eventually emoji were added, Unicode expanded to 17 planes totaling 1,114,112 code points (U+0000 to U+10FFFF). The 16 additional planes beyond the BMP are called Supplementary Planes. Characters in these planes have code points above U+FFFF and require a special encoding trick in UTF-16.

PlaneRangeNameExample characters
0 (BMP)U+0000..U+FFFFBasic Multilingual PlaneA, é, 漢, ♠
1 (SMP)U+10000..U+1FFFFSupplementary Multilingual🌍, 𝄞, 𐐀
2 (SIP)U+20000..U+2FFFFSupplementary Ideographic𠮟, 𠀀
3-16U+30000..U+10FFFFTertiary + unassigned𰀀 (Ext. G)

Languages like JavaScript, Java, and C# chose UTF-16 as their internal string encoding when 16 bits seemed sufficient. When Unicode outgrew 16 bits, these languages had to accommodate supplementary characters without changing their fundamental string type. The solution was surrogate pairs.

How Surrogate Pairs Encode Supplementary Characters

UTF-16 represents code points above U+FFFF using a pair of 16-bit code units called a surrogate pair. The algorithm is precise:

Given code point U (where U > 0xFFFF):

1. Subtract 0x10000:  U' = U - 0x10000
   (result is 0x00000 .. 0xFFFFF, exactly 20 bits)

2. Split into two 10-bit halves:
   High 10 bits: H = (U' >> 10) + 0xD800   → range 0xD800..0xDBFF
   Low 10 bits:  L = (U' & 0x3FF) + 0xDC00  → range 0xDC00..0xDFFF

Example: 🌍 = U+1F30D
   U' = 0x1F30D - 0x10000 = 0xF30D
   H  = (0xF30D >> 10) + 0xD800 = 0x3C + 0xD800 = 0xD83C
   L  = (0xF30D & 0x3FF) + 0xDC00 = 0x30D + 0xDC00 = 0xDF0D

So 🌍 in UTF-16: 0xD83C 0xDF0D

Unicode permanently reserved the range U+D800 to U+DFFF (2,048 code points) exclusively for surrogates. These values are not valid code points themselves — they exist only as encoding artifacts in UTF-16. The high surrogate (U+D800..U+DBFF) always comes first, followed by the low surrogate (U+DC00..U+DFFF).

This means UTF-16 can encode all 1,112,064 valid Unicode code points: 63,488 BMP characters directly (65,536 minus the 2,048 surrogates), plus 1,048,576 supplementary characters via surrogate pairs (1,024 high surrogates x 1,024 low surrogates).

Why "🌍".length === 2 in JavaScript

JavaScript strings are sequences of UTF-16 code units. The .length property counts these code units, not characters or code points:

ExpressionValueExplanation
"A".length1BMP character = 1 code unit
"漢".length1BMP character = 1 code unit
"🌍".length2Supplementary = surrogate pair = 2 code units
"🌍"[0]"\uD83C"High surrogate (not a valid character)
"🌍"[1]"\uDF0D"Low surrogate (not a valid character)
"🌍".charCodeAt(0)0xD83CHigh surrogate numeric value
"🌍".codePointAt(0)0x1F30DActual code point (ES6+)

This is not a “bug” — it is the fundamental reality of how JavaScript stores strings. Every string operation that uses index-based access (bracket notation, charAt, charCodeAt, substring, slice) operates on UTF-16 code units, not code points.

ES6 introduced code-point-aware alternatives: codePointAt(), String.fromCodePoint(), and the string iterator ([...str] or for...of). These correctly handle surrogate pairs as single units.

// ES6 code point iteration
const str = "A🌍B";

// WRONG: index-based
for (let i = 0; i < str.length; i++) {
  console.log(str[i]);
}
// Output: "A", "\uD83C", "\uDF0D", "B"  (4 iterations, broken emoji)

// RIGHT: iterator-based
for (const ch of str) {
  console.log(ch);
}
// Output: "A", "🌍", "B"  (3 iterations, correct)

Iterating Correctly Over Strings

Here are the safe and unsafe ways to iterate over JavaScript strings containing surrogate pairs:

MethodSurrogate-safe?Notes
for (i=0; i<str.length; i++)NoIterates UTF-16 code units
str.split('')NoSplits at code unit boundaries
for (const ch of str)YesUses string iterator (ES6)
[...str]YesSpread uses string iterator
Array.from(str)YesUses string iterator
str.match(/./gsu)YesWith 'u' flag, . matches code points
Intl.SegmenterYesAlso handles grapheme clusters

The u flag on regular expressions is critical. Without it, /./g matches individual code units, splitting surrogate pairs. With /./gu, the dot matches full code points. Similarly, \u{1F30D} syntax (requiring the u flag) lets you match supplementary characters directly in regex patterns.

// String reversal: a classic surrogate pair trap
const str = "A🌍B";

// WRONG: breaks surrogate pair
str.split('').reverse().join('');
// → "B\uDF0D\uD83C A" (corrupted, shows replacement chars)

// RIGHT: spread preserves pairs
[...str].reverse().join('');
// → "B🌍A" (correct)

// String length: count actual characters
[...str].length;  // 3 (correct)
str.length;       // 4 (counts code units)

Practical Bugs from Surrogate Pairs

Surrogate pair bugs are among the most common Unicode issues in production software. Here are real-world scenarios:

  • Database truncation: A VARCHAR(100) column that counts UTF-16 code units will truncate "99 chars + 🌍" at the high surrogate, producing a lone surrogate that poisons downstream processing.
  • JSON encoding: Lone surrogates (\uD83C without a following low surrogate) are technically invalid in JSON per RFC 8259. Some parsers reject them; others silently produce U+FFFD.
  • Substring extraction: str.substring(0, 3) on "A🌍B" returns "A" + the high surrogate of 🌍 — a corrupted string that may render as A�.
  • Twitter/SMS character limits: Twitter counts code points (not code units) for its character limit. A single emoji counts as 1 character toward the limit despite being .length === 2 in JavaScript.
  • Cursor movement in text editors: Pressing the right arrow key should skip over both code units of a surrogate pair. Many custom text input implementations get this wrong, placing the cursor between the high and low surrogate.

The safest approach: always use code-point-aware APIs (for...of, codePointAt, String.fromCodePoint) when processing text, and use Intl.Segmenter when counting user-visible characters.

// Safe substring that never splits surrogate pairs
function safeSubstring(str, start, end) {
  const chars = [...str];
  return chars.slice(start, end).join('');
}

safeSubstring("A🌍B", 0, 2); // "A🌍" (correct)
"A🌍B".substring(0, 2);      // "A\uD83C" (broken!)

// Detect if a string contains lone surrogates
function hasLoneSurrogates(str) {
  return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(str);
}

BMP の境界: U+FFFF

Unicode が 1980 年代後半に設計された当初、コンソーシアムは 65,536 個のコードポイント(16 ビット)ですべての言語のすべての文字を収容できると考えていました。この元の範囲 — U+0000 から U+FFFF — を基本多言語面(BMP: Basic Multilingual Plane)と呼びます。

しかし、それは誤りでした。より多くの文字体系、歴史的文字、そして絵文字が追加されるにつれ、Unicode は 17 面・合計 1,114,112 コードポイント(U+0000〜U+10FFFF)に拡張されました。BMP を超える 16 の追加面を補助面(Supplementary Planes)と呼びます。これらの面の文字は U+FFFF を超えるコードポイントを持ち、UTF-16 では特殊なエンコーディング手法が必要です。

範囲名称文字例
0 (BMP)U+0000..U+FFFF基本多言語面A, é, 漢, ♠
1 (SMP)U+10000..U+1FFFF追加多言語面🌍, 𝄞, 𐐀
2 (SIP)U+20000..U+2FFFF追加漢字面𠮟, 𠀀
3-16U+30000..U+10FFFF第三漢字面 + 未割当𰀀 (拡張G)

JavaScript、Java、C# は 16 ビットで十分と思われていた時代に UTF-16 を内部文字列エンコーディングとして採用しました。Unicode が 16 ビットを超えた際、これらの言語は基本的な文字列型を変更せずに補助文字に対応する必要がありました。その解決策がサロゲートペアです。

サロゲートペアによる補助文字のエンコード

UTF-16 は U+FFFF を超えるコードポイントを、サロゲートペアと呼ばれる 16 ビットコードユニットのペアで表現します。アルゴリズムは厳密に定義されています:

コードポイント U が与えられたとき(U > 0xFFFF の場合):

1. 0x10000 を減算:  U' = U - 0x10000
   (結果は 0x00000 .. 0xFFFFF、ちょうど20ビット)

2. 10ビットずつ2つに分割:
   上位10ビット: H = (U' >> 10) + 0xD800   → 範囲 0xD800..0xDBFF
   下位10ビット: L = (U' & 0x3FF) + 0xDC00  → 範囲 0xDC00..0xDFFF

例: 🌍 = U+1F30D
   U' = 0x1F30D - 0x10000 = 0xF30D
   H  = (0xF30D >> 10) + 0xD800 = 0x3C + 0xD800 = 0xD83C
   L  = (0xF30D & 0x3FF) + 0xDC00 = 0x30D + 0xDC00 = 0xDF0D

🌍 の UTF-16 表現: 0xD83C 0xDF0D

Unicode は U+D800 から U+DFFF の範囲(2,048 コードポイント)をサロゲート専用として永久に予約しています。これらの値自体は有効なコードポイントではなく、UTF-16 のエンコーディング上の産物としてのみ存在します。上位サロゲート(U+D800..U+DBFF)が必ず先に来て、下位サロゲート(U+DC00..U+DFFF)が続きます。

これにより UTF-16 は有効な Unicode コードポイント全 1,112,064 個をエンコードできます: BMP の 63,488 文字を直接(65,536 からサロゲート 2,048 を除外)、さらにサロゲートペアで 1,048,576 の補助文字(上位 1,024 x 下位 1,024)。

なぜ "🌍".length === 2 なのか

JavaScript の文字列は UTF-16 コードユニットの列です。.length プロパティはこのコードユニット数を返すのであって、文字数やコードポイント数ではありません:

説明
"A".length1BMP 文字 = 1 コードユニット
"漢".length1BMP 文字 = 1 コードユニット
"🌍".length2補助文字 = サロゲートペア = 2 コードユニット
"🌍"[0]"\uD83C"上位サロゲート(有効な文字ではない)
"🌍"[1]"\uDF0D"下位サロゲート(有効な文字ではない)
"🌍".charCodeAt(0)0xD83C上位サロゲートの数値
"🌍".codePointAt(0)0x1F30D実際のコードポイント(ES6+)

これは「バグ」ではなく、JavaScript が文字列を格納する根本的な仕組みです。インデックスベースのアクセスを使うすべての文字列操作(ブラケット記法、charAtcharCodeAtsubstringslice)は、コードポイントではなく UTF-16 コードユニット単位で動作します。

ES6 でコードポイント対応の代替手段が導入されました: codePointAt()String.fromCodePoint()、文字列イテレータ([...str]for...of)。これらはサロゲートペアを正しく 1 つの単位として処理します。

// ES6 コードポイント反復
const str = "A🌍B";

// 間違い: インデックスベース
for (let i = 0; i < str.length; i++) {
  console.log(str[i]);
}
// 出力: "A", "\uD83C", "\uDF0D", "B"  (4回、絵文字が壊れる)

// 正解: イテレータベース
for (const ch of str) {
  console.log(ch);
}
// 出力: "A", "🌍", "B"  (3回、正しい)

文字列の正しい反復処理

サロゲートペアを含む JavaScript 文字列を安全に / 非安全に反復する方法を比較します:

メソッドサロゲート安全?備考
for (i=0; i<str.length; i++)NoUTF-16 コードユニット単位で反復
str.split('')Noコードユニット境界で分割
for (const ch of str)Yes文字列イテレータを使用(ES6)
[...str]Yesスプレッドは文字列イテレータを使用
Array.from(str)Yes文字列イテレータを使用
str.match(/./gsu)Yes'u' フラグで . がコードポイントにマッチ
Intl.SegmenterYes書記素クラスタも処理可能

正規表現の u フラグは極めて重要です。なしの場合、/./g は個々のコードユニットにマッチし、サロゲートペアを分断します。/./gu にすると、ドットはコードポイント全体にマッチします。同様に、\u{1F30D} 構文(u フラグが必要)により、正規表現パターン内で補助文字を直接マッチできます。

// 文字列反転: サロゲートペアの典型的な落とし穴
const str = "A🌍B";

// 間違い: サロゲートペアが壊れる
str.split('').reverse().join('');
// → "B\uDF0D\uD83CA" (破損、置換文字が表示される)

// 正解: スプレッドでペアを保持
[...str].reverse().join('');
// → "B🌍A" (正しい)

// 文字列長: 実際の文字数をカウント
[...str].length;  // 3 (正しい)
str.length;       // 4 (コードユニットを数えている)

サロゲートペアに起因する実際のバグ

サロゲートペアのバグは、本番ソフトウェアで最もよく見られる Unicode 問題の一つです。実際のシナリオを紹介します:

  • データベースの切り詰め: UTF-16 コードユニット数でカウントする VARCHAR(100) カラムは、"99文字 + 🌍" を上位サロゲートで切断し、孤立サロゲートが後続処理を破壊します。
  • JSON エンコーディング: 孤立サロゲート(\uD83C が下位サロゲートなしで出現)は RFC 8259 上は無効な JSON です。パーサーによっては拒否され、他は U+FFFD に置換されます。
  • 部分文字列の抽出: "A🌍B" に対する str.substring(0, 3)"A" + 🌍 の上位サロゲートを返し、破損した文字列(A� と表示される可能性)になります。
  • Twitter/SMS の文字数制限: Twitter はコードポイント数(コードユニット数ではなく)で文字数をカウントします。絵文字 1 つは制限に対して 1 文字ですが、JavaScript の .length では 2 です。
  • テキストエディタのカーソル移動: 右矢印キーはサロゲートペアの両方のコードユニットをスキップすべきです。多くのカスタムテキスト入力実装がこれを誤り、上位・下位サロゲートの間にカーソルを置いてしまいます。

最も安全なアプローチ: テキスト処理には常にコードポイント対応 API(for...ofcodePointAtString.fromCodePoint)を使い、ユーザーに見える文字をカウントする場合は Intl.Segmenter を使用することです。

// サロゲートペアを分断しない安全な substring
function safeSubstring(str, start, end) {
  const chars = [...str];
  return chars.slice(start, end).join('');
}

safeSubstring("A🌍B", 0, 2); // "A🌍" (正しい)
"A🌍B".substring(0, 2);      // "A\uD83C" (壊れている!)

// 文字列に孤立サロゲートが含まれるか検出
function hasLoneSurrogates(str) {
  return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(str);
}