JavaScriptの文字列はUTF-16コード単位で保持されます。そのため、BMP外の絵文字や一部の漢字は2つのコード単位からなるサロゲートペアとして表現されます。
たとえば😀はJSON内で \\uD83D\\uDE00 と書けます。正しいペアなら JSON.parse() が1つのコードポイントへ戻しますが、上位サロゲートまたは下位サロゲートだけが残った入力は不正です。
まずJSONパーサーを使う
入力が完全なJSONなら、独自の置換処理より先にJSONパーサーへ渡します。
const value = JSON.parse('"\\uD83D\\uDE00"');
console.log(value); // 😀
console.log(value.length); // 2: UTF-16 code units
console.log([...value].length); // 1: Unicode code point
String.length はコードポイント数ではありません。文字数を数える目的では、要件に応じてスプレッド構文や Intl.Segmenter を使います。
孤立サロゲートを検出する
外部システムから受け取った文字列や、部分的に切り出したログでは、不正なコード単位が残る場合があります。
function findUnpairedSurrogates(text) {
const errors = [];
for (let i = 0; i < text.length; i += 1) {
const unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF) {
const next = text.charCodeAt(i + 1);
if (!(next >= 0xDC00 && next <= 0xDFFF)) {
errors.push({ index: i, type: 'unpaired-high-surrogate' });
} else {
i += 1;
}
continue;
}
if (unit >= 0xDC00 && unit <= 0xDFFF) {
errors.push({ index: i, type: 'unpaired-low-surrogate' });
}
}
return errors;
}
確認例です。
console.log(findUnpairedSurrogates('A😀B')); // []
console.log(findUnpairedSurrogates('A\uD83DB')); // high surrogate error
console.log(findUnpairedSurrogates('A\uDE00B')); // low surrogate error
自動修復よりエラー方針を決める
孤立サロゲートを見つけても、常に推測で補完できるわけではありません。どの文字だったかという情報が失われているためです。
用途ごとに次のいずれかを選びます。
- API入力: 400エラーとして拒否し、位置と理由を返す。
- ログ表示: 元データを保持したまま、画面表示だけ置換文字へ変える。
- データ移行: 行番号とオフセットを監査ログへ記録し、原本から再取得する。
- 検索インデックス: 正規化前の値も別フィールドへ保存する。
重要なのは、エラーを無視して保存しないことです。別のシステムやエンコーダーへ渡した時点で、原因の追跡がさらに難しくなります。
U+XXXX と \\uXXXX を区別する
U+1F600 はコードポイントの表記です。一方、JSONの \\uD83D\\uDE00 はUTF-16コード単位のエスケープです。同じ絵文字を示していても、入力形式と検証方法は異なります。
複数形式のサンプルを目視で比較したい場合は、ブラウザ内で処理する ASCII to Unicode を確認用に使えます。ただし本番処理では、JSONならJSONパーサー、バイト列なら TextDecoder のように、元の形式を所有する処理を使うべきです。
まとめ
サロゲートペアの問題は「文字化け」ではなく、UTF-16コード単位の整合性の問題です。完全なJSONは標準パーサーで処理し、その後に孤立サロゲートを検査します。不正な入力を推測で直すのではなく、拒否・表示用置換・原本再取得など、用途に合うエラー方針を明示すると再発を防げます。