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.
| Plane | Range | Name | Example characters |
|---|---|---|---|
| 0 (BMP) | U+0000..U+FFFF | Basic Multilingual Plane | A, é, 漢, ♠ |
| 1 (SMP) | U+10000..U+1FFFF | Supplementary Multilingual | 🌍, 𝄞, 𐐀 |
| 2 (SIP) | U+20000..U+2FFFF | Supplementary Ideographic | 𠮟, 𠀀 |
| 3-16 | U+30000..U+10FFFF | Tertiary + 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:
| Expression | Value | Explanation |
|---|---|---|
| "A".length | 1 | BMP character = 1 code unit |
| "漢".length | 1 | BMP character = 1 code unit |
| "🌍".length | 2 | Supplementary = surrogate pair = 2 code units |
| "🌍"[0] | "\uD83C" | High surrogate (not a valid character) |
| "🌍"[1] | "\uDF0D" | Low surrogate (not a valid character) |
| "🌍".charCodeAt(0) | 0xD83C | High surrogate numeric value |
| "🌍".codePointAt(0) | 0x1F30D | Actual 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:
| Method | Surrogate-safe? | Notes |
|---|---|---|
| for (i=0; i<str.length; i++) | No | Iterates UTF-16 code units |
| str.split('') | No | Splits at code unit boundaries |
| for (const ch of str) | Yes | Uses string iterator (ES6) |
| [...str] | Yes | Spread uses string iterator |
| Array.from(str) | Yes | Uses string iterator |
| str.match(/./gsu) | Yes | With 'u' flag, . matches code points |
| Intl.Segmenter | Yes | Also 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 (
\uD83Cwithout 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 asA�. - 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 === 2in 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-16 | U+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".length | 1 | BMP 文字 = 1 コードユニット |
| "漢".length | 1 | BMP 文字 = 1 コードユニット |
| "🌍".length | 2 | 補助文字 = サロゲートペア = 2 コードユニット |
| "🌍"[0] | "\uD83C" | 上位サロゲート(有効な文字ではない) |
| "🌍"[1] | "\uDF0D" | 下位サロゲート(有効な文字ではない) |
| "🌍".charCodeAt(0) | 0xD83C | 上位サロゲートの数値 |
| "🌍".codePointAt(0) | 0x1F30D | 実際のコードポイント(ES6+) |
これは「バグ」ではなく、JavaScript が文字列を格納する根本的な仕組みです。インデックスベースのアクセスを使うすべての文字列操作(ブラケット記法、charAt、charCodeAt、substring、slice)は、コードポイントではなく 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++) | No | UTF-16 コードユニット単位で反復 |
| str.split('') | No | コードユニット境界で分割 |
| for (const ch of str) | Yes | 文字列イテレータを使用(ES6) |
| [...str] | Yes | スプレッドは文字列イテレータを使用 |
| Array.from(str) | Yes | 文字列イテレータを使用 |
| str.match(/./gsu) | Yes | 'u' フラグで . がコードポイントにマッチ |
| Intl.Segmenter | Yes | 書記素クラスタも処理可能 |
正規表現の 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...of、codePointAt、String.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);
}