0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

絵文字1個の文字数が 8 と 5 と 1 に割れた — `.length` が数えているのは文字ではない

0
Last updated at Posted at 2026-08-09

同じ絵文字 👨‍👩‍👦 を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
UTF-8 バイト数は 18。どれを「文字数」と呼ぶかは、実装ではなく仕様の選択になる。

先行記事リスペクト — この記事を読む前に

この記事の差分: 先行記事は主に JavaScript 単体の数え方を扱っている。自分が足すのは、同一文字列を Node / Python / Ruby / Java / Swift の5処理系で走らせた横並びの実測ログと、数え間違いの「その先」——半分に切れた文字列が encodeURIComponentURIError になって送信フェーズで落ちるところまでの追跡。

想定読者

  • ユーザー入力を扱うフォーム・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

以降のコードは全部この環境で実行して、出力をそのまま貼っている。

目次

  1. 絵文字の文字数は、処理系ごとに3つの答えが出る
  2. length が数えているのは「文字」ではなく箱の数
  3. 文字列を切ると、絵文字が半分だけ残る
  4. 壊れた文字列は、送信のタイミングで落ちる
  5. 文字数バリデーションが、見た目2文字を却下する
  6. 数え方を3つから選ぶ
  7. 書記素で数えるやり方の留意点
  8. データベース側で詰まるバイト長
  9. 今日・今週・今月のアクション

絵文字の文字数は、処理系ごとに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 に壊れた文字列が保存される
  • encodeURIComponentURIError: 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 の utf8utf8mb3)は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 は素通しし、encodeURIComponentURIError で落ちる。壊した場所と落ちる場所が離れるのが一番厄介
  • .length での文字数制限は、見た目2文字の入力を「16文字」で却下する
  • 書記素で数えるのが見た目には一番近い。ただし1書記素で401バイトの入力が作れるので、バイト長との二重制限にする
  • MySQL は utf8mb4、テストデータに ZWJ 絵文字・国旗・結合文字の3種を常設する

自分が実測して一番効いたのは、個別の対策より 1つの数え方で表示も制限も切り詰めも全部やろうとしない という切り分けのほうだった。用途ごとに数え方を分ければ、上に並べた事故はどれも起きない。

正直、絵文字を1個テストデータに混ぜるだけで防げる話ではある。で、そのテストデータを入れる発想が出てこないのは、「文字数」を1種類だと思っているから。実際に数え直すまで、疑う対象にすら入らない。

参考リンク

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?