同じ絵文字 👨👩👦 を5つの処理系で数えたら、答えが 8 と 5 と 1 に割れた。バグではなく、全部仕様どおりの正解。
自分が引っかかったのは、この3つのうちどれを使うか決めないまま .length で文字数制限を書いていたところ。10文字までのフォームに家族の絵文字を2個入れて実際に試したら、見た目2文字の入力が「16文字なので却下」で弾かれた。.length は見た目の 8倍 の値を返している。
この記事は、その数え方の違いがどこから来るのか、どこで事故になるのか、どう選ぶのかを実測ログ付きで置いていく。結局のところ、数え方は3つあって用途で選ぶしかない、というのが自分の結論になった。
👨👩👦 を数えた実測値| 数え方 | 結果 | この値を返すもの |
|---|---|---|
| UTF-16 コードユニット | 8 | JS .length / Java .length()
|
| コードポイント | 5 | Python len() / Ruby .size
|
| 書記素クラスタ(見た目) | 1 | Swift .count / JS Intl.Segmenter
|
先行記事リスペクト — この記事を読む前に
-
JavaScript: 文字数を正確にカウントするには? by @suin —
Intl.Segmenterで数える実装の型。まずこれで「正しく数える書き方」を押さえるのが早い - JavaScriptで絵文字とサロゲートペアと結合文字とgrapheme clusterを正しく扱うのに少し苦労した話 by @amanoese — 用語の関係を整理したい人はここから
- 「文字数を数える」「1文字とは」問題、または、Intl.Segmenterへの警鐘 by @KageShiron — 書記素で数えれば解決、にはならないという反対側。この記事の「留意点」セクションはここに強く依っている
- 絵文字を入れたら文字数カウントが狂った時に見るページ by @KeithYokoma — Java 側の事情
- 拡張書記素クラスターのサイズの上限を設ける by @masakielastic — 上限設計の実務解
この記事の差分: 先行記事は主に JavaScript 単体の数え方を扱っている。自分が足すのは、同一文字列を Node / Python / Ruby / Java / Swift の5処理系で走らせた横並びの実測ログと、数え間違いの「その先」——半分に切れた文字列が encodeURIComponent で URIError になって送信フェーズで落ちるところまでの追跡。
想定読者
- ユーザー入力を扱うフォーム・API・DB を書いている
- 文字数制限やテキストの切り詰めを実装したことがある
- 「絵文字は2文字」くらいの知識はあるが、
👨👩👦が8になる理由は説明できない
Unicode の規格そのものを網羅する記事ではない。実装で踏む場所だけに絞る。
検証環境
| 対象 | バージョン |
|---|---|
| macOS | 15.7.4 |
| Node.js | v26.0.0 |
| Python | 3.14.5 |
| Ruby | 3.3.4 |
| Java (OpenJDK) | 17.0.16 |
| Swift | 6.1.2 |
以降のコードは全部この環境で実行して、出力をそのまま貼っている。
目次
- 絵文字の文字数は、処理系ごとに3つの答えが出る
- length が数えているのは「文字」ではなく箱の数
- 文字列を切ると、絵文字が半分だけ残る
- 壊れた文字列は、送信のタイミングで落ちる
- 文字数バリデーションが、見た目2文字を却下する
- 数え方を3つから選ぶ
- 書記素で数えるやり方の留意点
- データベース側で詰まるバイト長
- 今日・今週・今月のアクション
絵文字の文字数は、処理系ごとに3つの答えが出る
先に実測から置く。同じ 👨👩👦(家族の絵文字)を、5つの処理系で数えた。
// emoji-count.js — 同じ文字列を3つの粒度で数える
const s = "👨👩👦";
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
console.log("length:", s.length); // UTF-16 コードユニット数
console.log("Array.from:", Array.from(s).length); // コードポイント数
console.log("Segmenter:", [...seg.segment(s)].length); // 書記素クラスタ数
実行結果:
length: 8
Array.from: 5
Segmenter: 1
他の処理系も並べる。
| 処理系 | 式 | 結果 |
|---|---|---|
| JavaScript | s.length |
8 |
| Java | s.length() |
8 |
| Python | len(s) |
5 |
| Ruby | s.size |
5 |
| Java | s.codePointCount(0, s.length()) |
5 |
| JavaScript | Array.from(s).length |
5 |
| Swift | s.count |
1 |
| Ruby | s.grapheme_clusters.size |
1 |
| JavaScript | Intl.Segmenter |
1 |
Swift だけ素の .count が 1 を返す。Swift の String は最初から書記素クラスタの並びとして定義されているので、他の言語で Intl.Segmenter 相当を呼んで初めて届く値が、デフォルトで出てくる。
ぶっちゃけ、この表を最初に見たときは「どれかが間違ってる」と思った。違った。3つとも、それぞれ別のものを数えた正しい答えになっている。
length が数えているのは「文字」ではなく箱の数
なぜ 8 / 5 / 1 に割れるのか。👨👩👦 の中身を開くと分かる。
// codepoints.js — 文字列をコードポイントに分解して表示
const s = "👨👩👦";
console.log([...s].map(c => "U+" + c.codePointAt(0).toString(16).toUpperCase()).join(" "));
U+1F468 U+200D U+1F469 U+200D U+1F466
中身は5個のコードポイントだった。U+1F468(男性)、U+1F469(女性)、U+1F466(男の子)を、U+200D という ZWJ(ゼロ幅接合子) で糊付けしている。表示側がこの並びを見て「1個の家族の絵文字」として描く。これが 5 と 1 の差。
では 8 はどこから来るか。JavaScript と Java の文字列は UTF-16 で、1個の箱が16ビットしか入らない。U+1F468 のように U+FFFF を超えるコードポイントは1個の箱に収まらず、2個の箱に分割して格納される。この分割された2個組がサロゲートペアと呼ばれるもの。
絵文字3個 × 2箱 + ZWJ 2個 × 1箱 = 8。これが .length の 8 の正体になる。
.length は嘘をついていない。UTF-16 の箱の数を正確に返している。ただ、その数を「文字数」として使う側が意味を取り違える。
いくつか実測を並べる。
| 文字列 | JS .length
|
コードポイント数 | 見た目 |
|---|---|---|---|
a |
1 | 1 | 1 |
あ |
1 | 1 | 1 |
👍 |
2 | 1 | 1 |
🇯🇵 |
4 | 2 | 1 |
🧑🏽🚀 |
7 | 4 | 1 |
👨👩👦 |
8 | 5 | 1 |
国旗 🇯🇵 は「地域指示子」2個の組み合わせ、🧑🏽🚀 は人 + 肌色修飾子 + ZWJ + ロケット。見た目1文字あたりの .length は、実際に 1 から 8 まで散らばる。見た目に対する倍率で言うと、国旗が 4倍、宇宙飛行士が 7倍、家族が 8倍。同じ「1文字」なのに、.length の返す値は最大で 8倍 ぶれる。
で、この倍率が固定でないところが厄介だった。新しい絵文字が増えれば倍率も変わる。定数で吸収できる話ではない。
結合文字も同じ問題を起こす
絵文字だけの話ではないところが、自分が一番油断していた点。
# normalize.py — 見た目が同じ é を2通りで作って比較する
import unicodedata
a = "é" # é を1コードポイントで表現 (NFC)
b = "é" # e + アクセント記号 (NFD)
print(a == b) # False
print(len(a), len(b)) # 1 2
print(a.encode("utf-8"), b.encode("utf-8"))
print(unicodedata.normalize("NFC", b) == a) # True
False
1 2
b'\xc3\xa9' b'e\xcc\x81'
True
画面上は両方 é に見える。それでも == は False を返す。macOS のファイル名は歴史的に NFD 寄りなので、Mac で作ったファイル名を Linux 側で文字列比較すると一致しない事故がここから出る。日本語だと「が」を「か + 濁点」で持つケースが同じ形。
対策は比較の前に正規化を挟むことになる。
# compare.py — 比較する前に正規化を通す
from unicodedata import normalize
def same_text(x: str, y: str) -> bool:
"""見た目が同じ文字列を同一とみなす比較"""
return normalize("NFC", x) == normalize("NFC", y)
print(same_text("é", "é")) # True
文字列を切ると、絵文字が半分だけ残る
数え方の話が実害になるのは、切るときだった。
// slice.js — 絵文字をまたいで文字列を切る
const bio = "今日のランチ🍜と🍣、あと🍺";
console.log(".length:", bio.length); // 16
console.log("Array.from:", Array.from(bio).length); // 13
for (const n of [7, 8]) {
const cut = bio.slice(0, n);
console.log(`slice(0,${n}) ->`, JSON.stringify(cut), "| 表示:", cut, "| 正常:", cut.isWellFormed());
}
実行結果:
.length: 16
Array.from: 13
slice(0,7) -> "今日のランチ\ud83c" | 表示: 今日のランチ� | 正常: false
slice(0,8) -> "今日のランチ🍜" | 表示: 今日のランチ🍜 | 正常: true
slice(0, 7) はサロゲートペアの真ん中で切っている。前半の \uD83C だけが残り、ペアの相方を失った孤立サロゲートになった。表示は �(U+FFFD, 置換文字)に化ける。
厄介なのは、テストデータが ASCII と日本語だけだと .length でも Array.from でも結果が一致してしまうところ。実際に上の bio から絵文字を抜いて同じコードを回すと、どちらも同じ値を返して素通りする。差が出ないので、絵文字を1個混ぜるまで気づく手がかりがない。
isWellFormed() は Node 20 以降と最近のブラウザで使える。切った直後に通せば、孤立サロゲートをその場で検出できる。
壊れた文字列は、送信のタイミングで落ちる
孤立サロゲートが厄介なのは、その場では静かに通ることだった。
// downstream.js — 壊れた文字列を下流に流すとどうなるか
const bad = "今日のランチ🍜と🍣、あと🍺".slice(0, 7); // 孤立サロゲートを含む
console.log("JSON:", JSON.stringify({ bio: bad })); // 通る
try {
console.log("URI:", encodeURIComponent(bad)); // 落ちる
} catch (e) {
console.log("Error ->", e.constructor.name + ":", e.message);
}
const bytes = new TextEncoder().encode(bad);
console.log("TextEncoder 末尾3バイト:", Array.from(bytes).slice(-3));
JSON: {"bio":"今日のランチ\ud83c"}
Error -> URIError: URI malformed
TextEncoder 末尾3バイト: [ 239, 191, 189 ]
3つとも挙動が違うのが面倒なところ。
-
JSON.stringifyは エラーを出さずに通す。\ud83cというエスケープのまま素通りして、DB に壊れた文字列が保存される -
encodeURIComponentはURIError: URI malformedで落ちる。クエリパラメータに載せる箇所で例外 -
TextEncoderは例外を投げず、239 191 189(= U+FFFD の UTF-8 表現)に置換する
つまり、文字列を壊した場所と、エラーが出る場所が離れる。切っているのが一覧表示のコンポーネントで、落ちるのが共有リンクを生成する別の関数、という配置だと、スタックトレースに slice は出てこない。デバッグが長引くとしたら、原因は Unicode の知識不足ではなく、実際にはこの距離のほうになる。
JSON.stringify が素通しする分、例外で落ちるより静かに壊れたデータが積もるほうが性質が悪い。落ちてくれれば、その場で気づける。
文字数バリデーションが、見た目2文字を却下する
もう一つの実害が入力チェック。ニックネーム10文字まで、みたいなやつ。
// validate.js — .length による文字数制限が何を弾くか
const MAX = 10;
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
for (const s of ["abcdefghij", "🍜🍜🍜🍜🍜", "👨👩👦👨👩👦"]) {
const looks = [...seg.segment(s)].length;
console.log(
JSON.stringify(s).padEnd(26),
"length=" + String(s.length).padStart(2),
"| 判定:", s.length <= MAX ? "通過" : "却下",
"| 見た目:", looks + "文字"
);
}
"abcdefghij" length=10 | 判定: 通過 | 見た目: 10文字
"🍜🍜🍜🍜🍜" length=10 | 判定: 通過 | 見た目: 5文字
"👨👩👦👨👩👦" length=16 | 判定: 却下 | 見た目: 2文字
見た目2文字の入力が「16文字だから」と却下される。 ユーザー側からは意味不明なエラーになる。逆に絵文字5個は「10文字」として通る。同じ制限値なのに、実際に入る情報量が入力によってバラバラという状態。
ここでよくやる修正が「じゃあ Array.from で数えよう」なんだけど、これも半端だった。👨👩👦 は5になるので、家族の絵文字を2個入れると10でギリギリ通る。見た目2文字で上限いっぱいを消費する。結局、コードポイントに変えても見た目とはズレたままで、自分は問題を1段ずらしただけだった。
数え方を3つから選ぶ
整理すると、選択肢は3つある。「正しい数え方」は用途で変わる。
// counters.js — 3つの粒度をそれぞれ関数にする
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
/** UTF-16 コードユニット数。JS内部の都合。人間向けの文字数ではない */
const countUnits = (s) => s.length;
/** コードポイント数。Python の len() と同じ値になる */
const countCodePoints = (s) => Array.from(s).length;
/** 書記素クラスタ数。ユーザーが「何文字」と感じる数に一番近い */
const countGraphemes = (s) => [...segmenter.segment(s)].length;
/** UTF-8 バイト数。DB のカラム長やネットワーク上限に効く */
const countBytes = (s) => new TextEncoder().encode(s).length;
const s = "👨👩👦";
console.log(countUnits(s), countCodePoints(s), countGraphemes(s), countBytes(s));
// 8 5 1 18
使い分けの指針をこう置いた。
| やりたいこと | 使う数え方 |
|---|---|
| ユーザーに見せる「○文字」表示 | 書記素クラスタ |
| 入力の文字数制限 | 書記素クラスタ + バイト長の二重制限 |
| 文字列の切り詰め | 書記素クラスタの境界で切る |
| DB のカラム長を見積もる | UTF-8 バイト数 |
| 配列の添字操作 | コードポイント |
切り詰めは、書記素の配列にしてから切ると孤立サロゲートが出ない。
// truncate.js — 書記素の境界で安全に切り詰める
function truncate(str, maxGraphemes, suffix = "…") {
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
const chars = [...seg.segment(str)].map(x => x.segment);
if (chars.length <= maxGraphemes) return str;
return chars.slice(0, maxGraphemes).join("") + suffix;
}
const bio = "今日のランチ🍜と🍣、あと🍺";
console.log(truncate(bio, 7)); // 今日のランチ🍜…
console.log(truncate(bio, 7).isWellFormed()); // true
Python には標準の書記素分割がないので、regex パッケージの \X を使う形になる。
# truncate.py — Python で書記素単位に切る (pip install regex が前提)
import regex
def graphemes(s: str) -> list[str]:
"""書記素クラスタのリストに分解する"""
return regex.findall(r"\X", s)
def truncate(s: str, n: int, suffix: str = "…") -> str:
g = graphemes(s)
return s if len(g) <= n else "".join(g[:n]) + suffix
print(len(graphemes("👨👩👦"))) # 1
print(truncate("今日のランチ🍜と🍣、あと🍺", 7)) # 今日のランチ🍜…
1
今日のランチ🍜…
標準ライブラリだけで済ませたい場合、unicodedata にこの機能はない。自分は素直に regex(検証時 2026.7.19)を入れた。
書記素で数えるやり方の留意点
「書記素で数えれば全部解決」で終わらせると、別の穴が開く。ここは @KageShiron さんの記事 の指摘が的確なので、自分の実測で裏を取った範囲だけ書く。
書記素クラスタ1個のバイト数に上限がない。 結合文字はいくらでも積める。「1書記素」として通るのに、UTF-8 で数百バイトになる入力が作れる。書記素だけで制限をかけると、10文字制限のフォームに数キロバイトの文字列を投げ込まれる。
// abuse.js — 書記素1個で何バイトまで積めるか
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
const evil = "a" + "́".repeat(200); // a に結合アクセントを200個
console.log("書記素数:", [...seg.segment(evil)].length); // 1
console.log("UTF-8バイト:", new TextEncoder().encode(evil).length); // 401
書記素数: 1
UTF-8バイト: 401
書記素1個で401バイト。a 1文字が1バイトなので、実に 401倍 の中身が「1文字」として通る。制限を書記素だけに寄せると、この入力は素通りする。実務では書記素数とバイト数の二重制限を置くことになる。
正直、自分はここを読むまで書記素カウントを万能だと思っていた。結局どの粒度にも抜け道があって、組み合わせるしかない。
留意点をもう2つ。
-
Intl.Segmenterの結果は環境で変わりうる。 分割規則は Unicode のバージョンに紐づく(UAX #29)。新しい絵文字が追加された直後は、ブラウザ・Node・DB で数え方がズレる期間がある。サーバとクライアントの両方で数えて突き合わせる設計にすると、そこで不一致が出る - 表示幅の計算には使えない。 書記素1個でも、全角なら半角2個分の幅を取る。等幅レイアウトを組むときに書記素数を幅として使うと崩れる
結局、表示用のカウンタは書記素、保存時のバリデーションは書記素とバイト数の両方、という分け方に落ち着く。1箇所で全部を賄おうとすると、どれかの用途で必ずズレが出る。
データベース側で詰まるバイト長
アプリ側を直しても、保存で落ちるパターンが残る。MySQL の utf8(utf8mb3)は1文字あたり最大3バイトしか持てない。
# bytes.py — 絵文字と日本語の UTF-8 バイト数を比べる
for s in ["a", "あ", "👍", "👨👩👦"]:
print(repr(s), "->", len(s.encode("utf-8")), "bytes")
'a' -> 1 bytes
'あ' -> 3 bytes
'👍' -> 4 bytes
'👨👩👦' -> 18 bytes
ひらがなは3バイトに収まるが、絵文字は4バイト。utf8mb3 のカラムに 👍 を入れると保存時にエラーか切り捨てが起きる。MySQL 側は utf8mb4 を使うことで解決する(MySQL 8.4 のドキュメント)。PostgreSQL は UTF8 が最初から4バイトを扱えるので、この問題は出ない(PostgreSQL のマルチバイト対応)。
もう一つ、バイト単位で切る処理も事故になる。
# cut_bytes.py — バイト数で切ると文字の途中で割れる
s = "ランチは🍜でした"
b = s.encode("utf-8")[:14] # 14バイトで切る
try:
print(b.decode("utf-8"))
except UnicodeDecodeError as e:
print("UnicodeDecodeError:", e)
UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 12-13: unexpected end of data
Python は UnicodeDecodeError で止まってくれる分、JavaScript の TextEncoder が黙って U+FFFD に置換するのより親切だった。バイト長で切る必要がある場合は decode("utf-8", errors="ignore") で末尾の欠片を捨てるか、切る位置を文字境界に丸める。
今日・今週・今月のアクション
今日(10分)
手元のコードで .length を使った文字数チェックを洗い出す。
# 文字数制限まわりで .length / len() を使っている箇所を拾う
rg -n '\.length\s*[<>=]{1,2}\s*\d+' --type js --type ts
rg -n 'len\([a-zA-Z_]+\)\s*[<>=]{1,2}\s*\d+' --type py
ヒットした箇所に 👨👩👦 を1文字入れてみる。想定と違う判定が出たらそこが直す対象になる。
今週(30分)
切り詰め処理を書記素境界に寄せる。この記事の truncate() をそのまま使えるなら差し替えて、切った直後に isWellFormed() を通すアサーションを1行足す。Node 20 未満なら /[\uD800-\uDBFF]$/.test(s) で末尾の孤立サロゲートを見る。
保存系のバリデーションには、書記素数の上限と UTF-8 バイト数の上限を両方置く。片方だけだと @masakielastic さんが書いている 上限なし問題が残る。
今月
DB のカラム定義を確認する。MySQL なら以下で utf8mb3 のカラムを洗い出せる。
-- utf8mb4 になっていないカラムを探す
SELECT TABLE_NAME, COLUMN_NAME, CHARACTER_SET_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND CHARACTER_SET_NAME IS NOT NULL
AND CHARACTER_SET_NAME <> 'utf8mb4';
あわせて、テストデータに絵文字を常設する。自分がこの記事の検証で使ったのは 👨👩👦(ZWJ 連結)、🇯🇵(地域指示子)、é(結合文字)の3つ。フィクスチャに入れておくと、コードユニット・コードポイント・書記素の差が全部テストに出る。3つとも .length のズレ方が違う(8 / 4 / 2)ので、1種類だけでは拾えないケースが埋まる。
まとめ
-
.lengthは UTF-16 の箱の数を返す。👨👩👦で 8、コードポイントで 5、見た目で 1。3つとも正しい - 切る位置を間違えると孤立サロゲートが残る。
JSON.stringifyは素通しし、encodeURIComponentはURIErrorで落ちる。壊した場所と落ちる場所が離れるのが一番厄介 -
.lengthでの文字数制限は、見た目2文字の入力を「16文字」で却下する - 書記素で数えるのが見た目には一番近い。ただし1書記素で401バイトの入力が作れるので、バイト長との二重制限にする
- MySQL は
utf8mb4、テストデータに ZWJ 絵文字・国旗・結合文字の3種を常設する
自分が実測して一番効いたのは、個別の対策より 1つの数え方で表示も制限も切り詰めも全部やろうとしない という切り分けのほうだった。用途ごとに数え方を分ければ、上に並べた事故はどれも起きない。
正直、絵文字を1個テストデータに混ぜるだけで防げる話ではある。で、そのテストデータを入れる発想が出てこないのは、「文字数」を1種類だと思っているから。実際に数え直すまで、疑う対象にすら入らない。
参考リンク
- String.prototype.length — MDN
- Intl.Segmenter — MDN
- String.prototype.isWellFormed — MDN
- TextEncoder — MDN
- ECMA-262: Text Processing
- UAX #29: Unicode Text Segmentation
- UTS #51: Unicode Emoji
- unicodedata — Python 標準ライブラリ
- Unicode HOWTO — Python ドキュメント
- String — Swift Standard Library
- The utf8mb4 Character Set — MySQL 8.4
- Character Set Support — PostgreSQL
- tc39/proposal-intl-segmenter