'😀'.length は 1 ではなく 2 になります。入力欄の「あと○文字」表示や、文字列の切り詰めで絵文字が壊れる原因はほぼこれです。本記事では、length・スプレッド構文・Intl.Segmenter の3つで「1文字」の数え方がどう違うかを実測します。
検証環境は Node.js v24.14.1 です。表示している出力はすべて実際に実行した結果です。
結論: 数え方は3種類ある
| 方法 | 数えているもの |
|---|---|
str.length |
UTF-16コードユニット(16ビットの塊)の数 |
[...str].length |
コードポイント(Unicodeの番号1つ)の数 |
Intl.Segmenter(grapheme) |
人間が1文字と感じる単位(書記素)の数 |
実測する
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
for (const s of ['abc', 'あいう', '😀', '👍🏽', '👨👩👧', '🇯🇵', '𠮷']) {
console.log(JSON.stringify(s).padEnd(10),
'length:', s.length,
'| [...s]:', [...s].length,
'| grapheme:', [...seg.segment(s)].length);
}
"abc" length: 3 | [...s]: 3 | grapheme: 3
"あいう" length: 3 | [...s]: 3 | grapheme: 3
"😀" length: 2 | [...s]: 1 | grapheme: 1
"👍🏽" length: 4 | [...s]: 2 | grapheme: 1
"👨👩👧" length: 8 | [...s]: 5 | grapheme: 1
"🇯🇵" length: 4 | [...s]: 2 | grapheme: 1
"𠮷" length: 2 | [...s]: 1 | grapheme: 1
ひらがなや英数字では3つの方法が一致するので、ここで気づけません。ズレるのは、サロゲートペア(UTF-16で2コードユニットを使う文字)と、複数のコードポイントを合成した文字です。
-
😀は U+1F600 の1コードポイントですが、UTF-16では2コードユニットなのでlengthは2 -
👍🏽は「👍」(U+1F44D)+肌色修飾子(U+1F3FD)の2コードポイント -
👨👩👧は3人の絵文字を U+200D(ゼロ幅接合子)でつないだ5コードポイント。lengthは8 -
🇯🇵は国旗を表す2つの地域指示記号。見た目は1文字 - 「𠮷」(つちよし)のような一部の漢字もサロゲートペアで、
lengthは2
切り詰めると壊れる
console.log(JSON.stringify('A😀B'.slice(0, 2)));
console.log('A😀B'.slice(0, 2).isWellFormed());
"A\ud83d"
false
slice(0, 2) が絵文字の途中(上位サロゲートだけ)で切れ、不正な文字列(lone surrogate)になりました。これを encodeURIComponent に渡すと実際に例外になります。
URIError: URI malformed
画面では A� のように表示され、APIに送れば文字化けや例外の原因になります。String.prototype.isWellFormed() は不正なサロゲートが混じっていないかの確認に使えます。
安全に切る: Intl.Segmenter
const text = '今日は🇯🇵👨👩👧とご飯';
console.log(JSON.stringify(text.slice(0, 5)));
console.log([...seg.segment(text)].slice(0, 5).map(x => x.segment).join(''));
console.log(text.length, [...text].length, [...seg.segment(text)].length);
"今日は🇯"
今日は🇯🇵👨👩👧
18 13 8
slice は国旗の片割れ(🇯 単体の記号)だけを残して、国旗として壊れます。しかもこの結果は isWellFormed() では true になります(Node v24.14.1 で確認)。サロゲートペアは壊れていなくても、書記素は壊れている、という状態です。Intl.Segmenter で書記素単位に分けて5つ取れば、国旗も家族の絵文字も壊れません。同じ文字列でも、length は18、スプレッドは13、書記素は8で、3通りの答えになります。
どれを使うか
- 文字数制限(入力フォームなど。X のように独自の重み付けで数えるサービスもある点に注意)を「人間に見える文字数」で扱うなら
Intl.Segmenter - DBのカラム長やAPIの仕様が「UTF-16」「バイト」「コードポイント」のどれで数えるかは、仕様側を確認してから決める。
lengthが正しい場面もある - 絵文字を含まないと確実に言える範囲(ID、英数字のコードなど)なら
lengthで問題ない - 文字を1つずつ逆順にしたいときの
split('').reverse().join('')も、サロゲートペアを壊す。[...str]ならサロゲートペアは守れますが、合成絵文字や国旗はまだ壊れます([...'🇯🇵'].reverse().join('')は 🇵🇯 になりました)。絵文字を含むなら Segmenter で分けてから reverse します
Intl.Segmenter は最近のブラウザとNode.jsで使えますが、古い実行環境では未対応の可能性があります。対象環境は事前に確認してください。
AIが書いたコードで .length や .slice を見かけたら
Claude Code などのAIは、文字数制限のコードを if (text.length > 140) や text.slice(0, 140) + '…' と書くことがあります。英数字だけのテストでは動くので、そのままレビューを通りやすい箇所です。次の2点を確認すると安全です。
- 絵文字や国旗を含む入力でテストしたか(
'👨👩👧'.lengthが8になる前提で数字を決めているか) - 「何を1文字と数える仕様か」を決めたうえで書かれているか。決まっていなければ、AIに「絵文字を含めた場合の文字数の数え方を比較して」と聞いて比較させると、確認が早いです
仕様が決まっていないまま「とりあえず length」と書かれたコードが、AIコード特有の見落としどころです。
未経験者向けの講座を運営しています
未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。
- ▶ ライブデモ(インストール不要・無料で今すぐ見られます)→ https://trial-web-ayies128s-projects.vercel.app/?ref=qiita
- 教材完全版+月5,500円のメンタリング(全24セッション+チャット質問し放題)→ https://menta.work/plan/20251?ref=qiita
- 無料体験版(git clone して自分の手元で動かす・最初の数セッション分・⭐ Star もよろしくお願いします)→ https://github.com/ayies128/next-ai-camp-trial
- YouTube『AIエンジニア情報局』(AI×開発ニュースを1本5分でキャッチアップできる別運営チャンネル・無料)→ https://www.youtube.com/channel/UC1rXVD9WYsQPQEWZyd-A1KA/?ref=qiita
※ Qiita 読者の方には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。