TL

テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント)

絵文字の文字数がずれる謎が原理から解ける。JS文字列がUTF-16コード単位列である帰結、サロゲートペア、lengthとIntl.Segmenterの違いまで一気に整理し、文字数バグを根絶します。

応用JavaScriptUnicodeUTF-16サロゲートペア文字コード最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、添字もlengthもコード単位を数える。文字(コードポイント)や見た目の1文字(書記素)とは一致しない。
  • 2.U+10000以上の文字(多くの絵文字や一部漢字)は2つのコード単位=サロゲートペアで表される。そのためlengthが2と数え、添字で半分だけ切ると壊れる。
  • 3.コードポイント単位で正しく扱うにはfor...ofやArray.from、見た目の1文字単位で数えるにはIntl.Segmenterを使う。lengthは「文字数」ではない。

なぜ「文字列の長さ」がずれるのか

絵文字を1個入れただけで length2 を返す、slice で文字が文字化けする、入力欄の文字数カウントが見た目とずれる——これらは個別のバグではなく、JavaScriptの文字列がUTF-16のコード単位列であるという設計から導かれる必然です。原理を押さえれば、どの操作が安全でどこに Intl.Segmenter を入れるべきかを推測でなく仕組みから判断できます。前提として基礎は JavaScript を押さえておくと、ここでの内部表現がつながります。

Unicodeの三層:コードポイント・コード単位・書記素

まず用語を分けます。これらを混同することが、文字数バグのほぼすべての原因です。

概念意味JSでの数え方
コードポイントUnicodeが文字に割り当てた番号(U+0000〜U+10FFFF)for...of / Array.from / codePointAt
コード単位 (UTF-16)符号化後の16bit値1個。文字列の物理的な構成要素length / 添字 / charCodeAt
書記素クラスタ人が見て1文字と感じる単位(結合文字や絵文字連結を1つに)Intl.Segmenter(granularity: grapheme)

Unicodeは文字にコードポイントという番号を振りますが、メモリ上にどう並べるかは別問題で、その符号化方式の一つがUTF-16です。UTF-16はコードポイントを16bitのコード単位1個または2個で表します。さらに人が「1文字」と感じる単位は書記素クラスタで、これはコードポイント1個とも限りません。JavaScriptの length が数えるのは、この三層のうちコード単位です。

JS文字列はUTF-16コード単位の並び

ECMAScript仕様では、文字列は16bitの符号なし整数(コード単位)の列と定義されます。添字 str[i]str.lengthstr.charCodeAt(i) も、すべてこのコード単位を基準に動きます。U+FFFF以下の文字(ASCII、日本語の大半、基本多言語面=BMPの文字)はコード単位1個で表されるため、この範囲だけ使う限り「length = 文字数」が偶然一致します。

"abc".length;        // 3
"あいう".length;     // 3(各文字がBMP内、1コード単位ずつ)
"あ".charCodeAt(0);  // 12354 (= 0x3042)

ところがこの一致はBMP内に限った偶然であり、U+10000以上の文字が混ざった瞬間に崩れます。仕様上 length は「コード単位数」であって「文字数」ではないからです。

サロゲートペア:U+10000以上を2個で表す

UTF-16は16bitなので、単独では 0x00000xFFFF(BMP)しか表せません。U+10000以上の文字(補助面)は、2つのコード単位の組=サロゲートペアで表現します。具体的には、0xD8000xDBFF上位サロゲートと、0xDC000xDFFF下位サロゲートを連続させます。この 0xD8000xDFFF の範囲はサロゲート専用に予約され、単独の文字としては使われません。

コードポイント C(U+10000以上)からペアへの変換は次の通りです。

v  = C - 0x10000              # 0x00000〜0xFFFFF(20bit)に収まる
hi = 0xD800 + (v >> 10)       # 上位10bit → 上位サロゲート
lo = 0xDC00 + (v & 0x3FF)     # 下位10bit → 下位サロゲート

20bitを上位・下位10bitずつに分け、それぞれサロゲート領域へ写像します。逆向きの復号は C = (hi - 0xD800) * 0x400 + (lo - 0xDC00) + 0x10000 です。絵文字 😀(U+1F600)を例に挙げます。

"😀".length;             // 2(サロゲートペアなので2コード単位)
"😀".charCodeAt(0);      // 55357 (0xD83D, 上位サロゲート)
"😀".charCodeAt(1);      // 56832 (0xDE00, 下位サロゲート)
"😀".codePointAt(0);     // 128512 (0x1F600, 正しいコードポイント)
[..."😀"].length;        // 1(コードポイント単位で数える)

charCodeAt がコード単位を返すのに対し、codePointAt は上位サロゲートを見つけると下位とペアにして正しいコードポイントを返します。この一語の違いが正否を分けます。

添字スライスはサロゲートを割る

str.slice(0, 1)str[0] はコード単位境界で切るため、サロゲートペアの片割れ(上位だけ)を取り出してしまいます。残された半分は対応する相手のいない**孤立サロゲート(lone surrogate)**となり、表示すると置換文字 U+FFFD(◇)に化けます。「先頭N文字だけ表示」を添字で実装すると絵文字や一部漢字(𠮷 など)で壊れるのは、ほぼ必ずこれが原因です。

length は文字数ではない:正しい数え方

length = 文字数」という思い込みを捨てるのが核心です。目的に応じて数える単位を選びます。

数えたいもの正しい手段誤用しがちな手段
コード単位(記憶域の長さ)str.length—(これは正しくコード単位)
コードポイント数[...str].length / Array.from(str).lengthstr.length
見た目の1文字(書記素)数Intl.Segmenter(grapheme)str.length / [...str].length

ES2015以降、文字列の反復子(iterator)はコードポイント単位で動きます。for...of・スプレッド [...str]Array.from(str) はサロゲートペアを1要素として扱うため、コードポイント数を正しく数えられます。一方、添字ループ(for (let i = 0; i < str.length; i++))はコード単位単位なので、サロゲートを割ります。

const s = "a😀b";
s.length;            // 4(a=1, 😀=2, b=1)
[...s].length;       // 3(コードポイント単位)
[...s];              // ["a", "😀", "b"]
正規表現の u フラグでコードポイント単位にする

正規表現は既定でコード単位ベースですが、u(Unicode)フラグを付けるとコードポイント単位でマッチします。/./u は絵文字1個に一致しますが、/./(uなし)は半分の上位サロゲートだけに一致します。\u{1F600} のようなコードポイント表記も u フラグ下でのみ有効です。文字単位で扱う正規表現には原則 u を付けます。

書記素クラスタ:コードポイントでも足りない

コードポイント単位でも、まだ「見た目の1文字」とは一致しません。複数のコードポイントが結合して1つの書記素クラスタを作るからです。代表例が次です。

  • 結合文字e(U+0065)+ 結合アクセント(U+0301)で é に見えるが、コードポイントは2個。
  • 絵文字のZWJシーケンス:👨‍👩‍👧 のような家族絵文字は、複数の絵文字をゼロ幅接合子(ZWJ, U+200D)で連結したもので、コードポイントは多数(この例では👨/ZWJ/👩/ZWJ/👧)。
  • 肌色修飾子や国旗:絵文字に修飾コードポイントが続く構成。

これらは [...str].length でも「1」になりません。人が数える「文字数」に合わせるには、書記素境界を解決する Intl.Segmenter を使います。

const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
const s = "👨‍👩‍👧é";
[...s].length;                    // 6(コードポイント数:絵文字3+ZWJ2+é=1…ではなく構成依存)
[...seg.segment(s)].length;       // 2(書記素:家族絵文字1+é1)

Intl.Segmenter は単語境界(word)や文境界(sentence)の分割も担い、書記素分割では国・言語ごとのUnicode規則に従って結合列を1単位にまとめます。「ユーザーに見せる文字数」「カーソル移動」「先頭N文字の切り出し」など、人の知覚に合わせる処理はこれが基準です。

三つの数え方の使い分けを問われる

"😀".length2(コード単位)、[..."😀"].length1(コードポイント)、é(結合文字版)の書記素数は 1Intl.Segmenter)。資格・面接では「lengthは文字数ではなくUTF-16コード単位数」という一点と、コードポイント単位(反復子・uフラグ)と書記素単位(Intl.Segmenter)の住み分けが問われやすい点を押さえてください。

実務での落とし穴と対策

帰結を実務に落とすと、危険なのはコード単位境界で文字列を分割・切断する操作です。slice/substring/添字アクセスで「先頭N文字」を作ると、N文字目がサロゲートペアや書記素の途中だった場合に壊れます。対策は次の通りです。

  • 表示用の切り詰めは書記素単位Intl.Segmenter で分割してからN個取り、結合し直す。コード単位の slice を直接使わない。
  • 文字数バリデーションは目的に合わせる:DBのバイト長制限なら符号化後のバイト数、ユーザー向け「N文字まで」なら書記素数で数える。maxlength 属性はコード単位基準である点に注意。
  • 入力のサニタイズで孤立サロゲートを除く:外部入力に孤立サロゲートが混ざると、保存・転送時に U+FFFD へ化けたり比較が狂ったりする。String.prototype.toWellFormed()(ES2024)で整形できる。
  • 正規化を忘れないé は単一コードポイント(U+00E9)でも結合列でも表せる。比較・重複判定の前に str.normalize("NFC") で正規化しないと、見た目が同じ文字列が不一致になる。

UTF-16の生バイト列を直接扱う場面や、サニタイズで境界を割って危険なマークアップが通る経路は、バイナリ表現の理解とも地続きです。バイト列としての扱いは TypedArray・ArrayBuffer・DataViewのメモリレイアウト、入力の浄化が破綻する経路は DOM-based XSSのsink/source分類 も合わせて見ると理解が深まります。

まとめ

まとめ

JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、length・添字・charCodeAt はすべてコード単位を数えます。U+10000以上の文字は2つのコード単位=サロゲートペアで表されるため、length は文字数とずれ、添字スライスはペアを割って孤立サロゲートを生みます。コードポイント単位で正しく扱うには反復子(for...of/スプレッド/Array.from)や正規表現の u フラグを、人が見る「1文字」で数えるには結合列を1単位にまとめる Intl.Segmenter を使います。「lengthは文字数ではなくコード単位数」を起点に、コードポイントと書記素の住み分けを意識すれば、絵文字の文字数バグは推測でなく仕組みから防げます。数値の内部表現に興味があれば JavaScriptの数値表現とIEEE754 も同じ「内部表現の帰結」という観点でつながります。

Web/フロントエンド Article

テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント)を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

JavaScript

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

U+10000以上の文字(多くの絵文字や一部漢字)は2つのコード単位=サロゲートペアで表される。そのためlengthが2と数え、添字で半分だけ切ると壊れる。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「JavaScript / Unicode」に近いか確認する。
  • 強みである「JavaScriptの文字列はUTF-16のコード単位(16bit)の並びで、添字もlengthもコード単位を数える。文字(コードポイント)や見た目の1文字(書記素)とは一致しない。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

JavaScriptUnicodeUTF-16サロゲートペア文字コードJavaScriptUnicodeUTF-16
参考: 公式情報