アプリのレビュー欄に、短い報告が一件だけ届いたことがあります。「名前が保存できません」。文面はそれだけでした。
再現できませんでした。日本語で試しても、英語で試しても、20文字の上限どおりに動きます。何度か往復して、ようやく相手の入力が見えてきました。名前の末尾に家族の絵文字が並んでいたのです。
手元で同じ文字列を打ち込むと、入力欄のカウンタは「20 / 20」と表示されているのに、保存ボタンを押すとサーバーが弾き返してきました。画面は正しいと言い、サーバーは間違っていると言う。どちらも自分が書いたコードでした。
原因は単純で、しかし気付きにくいものでした。クライアントとサーバーが、別々の方法で文字を数えていたのです。
個人開発でアプリを何本か抱えていると、こういう「片方だけ正しい」バグは静かに増えていきます。今回はこの一件を起点に、数え方そのものを設計対象として扱い直した記録です。数値はすべて Node v22.22.3 のサンドボックスで実際に走らせて取りました。
同じ名前を、実装ごとに違う長さで数えている
まず、代表的な文字列を4つの方法で数えてみます。.length(UTF-16 コードユニット数)、スプレッド構文による [...s].length(コードポイント数)、Intl.Segmenter による書記素クラスタ数、そして UTF-8 でのバイト数です。
// seg_probe.mjs — 4つの数え方を並べて比較する
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const graphemeCount = (s) => {
let n = 0;
for (const _ of seg.segment(s)) n++;
return n;
};
const samples = [
['ASCII', 'Hello'],
['日本語', 'こんにちは'],
['家族(ZWJ結合)', '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'],
['肌色修飾子つき', '\u{1F44D}\u{1F3FD}'],
['国旗(地域指示子)', '\u{1F1EF}\u{1F1F5}'],
];
for (const [label, s] of samples) {
console.log([
label,
s.length, // UTF-16 コードユニット
[...s].length, // コードポイント
graphemeCount(s), // 書記素クラスタ
Buffer.byteLength(s, 'utf8'), // UTF-8 バイト
].join('\t'));
}
実行結果です。左から順に、UTF-16 コードユニット数・コードポイント数・書記素数・UTF-8 バイト数を並べています。
| 入力 | .length | [...s].length | 書記素 | UTF-8 バイト |
| Hello | 5 | 5 | 5 | 5 |
| こんにちは | 5 | 5 | 5 | 15 |
| が(濁点を分解した形) | 2 | 2 | 1 | 6 |
| 👨👩👧👦(家族) | 11 | 7 | 1 | 25 |
| 👍🏽(肌色修飾子つき) | 4 | 2 | 1 | 8 |
| 🇯🇵(国旗) | 4 | 2 | 1 | 8 |
| 葛󠄀(異体字セレクタつき) | 3 | 2 | 1 | 7 |
| 각(ハングルを字母に分解した形) | 3 | 3 | 1 | 9 |
| กำ(タイ語) | 2 | 2 | 1 | 6 |
| ❤️(異体字セレクタ16つき) | 2 | 2 | 1 | 6 |
| 👩💻(職業を表す結合) | 5 | 3 | 1 | 11 |
家族の絵文字が、実装によって 11・7・1・25 と読まれます。人間が見て「1文字」と感じるものに対して、4つとも違う答えを返しているわけです。
ここで注意しておきたいのは、[...s].length の立ち位置です。.length の素朴さを知った人が最初に手を出す修正先で、私も長らくそう書いていました。ところがこの表を見ると、[...s] は「正しい方向へ一歩近づいた」のではありません。家族の絵文字を 11 と数えるか 7 と数えるかの違いでしかなく、人間の感覚である 1 には届いていない。中途半端に直したぶん、問題が見えにくくなった状態だと言えます。
20文字の入力欄に、絵文字はいくつ入るのか
上限を 20 と決めたとして、その 20 が何を指しているのかで、入る絵文字の数は劇的に変わります。同じ家族の絵文字で試した結果です。
| 上限20の解釈 | 入る個数 | この判定を書きがちな場所 |
.length <= 20 | 1個 | React Native の TextInput と maxLength |
[...s].length <= 20 | 2個 | 「絵文字対応した」つもりのバリデータ |
| 書記素 20 以下 | 20個 | ユーザーの期待 |
| UTF-8 で 20 バイト以下 | 0個 | DB のカラム定義・API のバイト制限 |
同じ「20文字」という言葉で、1個から20個まで開きが出ます。最後の行は特に厄介です。1個も入らない。バイト長で切っている API やカラムを持っていると、絵文字を1つ入れた時点で拒否されます。
私が踏んだのは、この表の1行目と3行目の食い違いでした。クライアントは書記素で数えて「20 / 20」と表示し、サーバーは .length で数えて 220 という値を見ていた。どちらのコードも自分で書いたのに、数え方を揃えるという発想がそもそも無かったのです。
途中で切ると何が壊れるか
上限を超えた入力を黙って切り詰める実装は珍しくありません。value.slice(0, 20) と書くだけですし、実際にそう書いていました。
これがどれくらい壊れるのかを、乱数で組み立てた文字列 2,000 件(絵文字・結合文字・通常文字を混ぜたもの)に対して実測しました。
// trunc_probe.mjs — UTF-16 単位の slice が何を壊すか
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const LONE_SURROGATE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
function inspect(source, limit) {
const cut = source.slice(0, limit); // 素朴な切り詰め
const roundTrip = Buffer.from(cut, 'utf8').toString('utf8'); // 送信を模した往復
return {
lone: LONE_SURROGATE.test(cut), // 孤立サロゲートが残ったか
replaced: roundTrip.includes('�'), // 置換文字になったか
};
}
2,000 件の結果です。
| 観測項目 | 件数 | 割合 |
| 書記素の途中で切れた | 1,513 / 2,000 | 75.6% |
| 孤立サロゲートが残った | 645 / 2,000 | 32.3% |
| UTF-8 で往復させると置換文字(U+FFFD)になった | 723 / 2,000 | 36.1% |
ここで一つ、予想と食い違った点がありました。JSON.stringify と JSON.parse を通しただけでは、置換文字は 0 件だったのです。孤立サロゲートは \uD83D のようなエスケープとして保存され、往復して元に戻ります。手元のテストで「壊れていない」と判断してしまいがちなのはこのためです。
壊れるのは、実際に UTF-8 のバイト列にした瞬間でした。fetch でリクエストボディを送る、ファイルへ書き出す、といった段階を通って初めて 36.1% が置換文字に変わります。ユニットテストが緑のまま、本番でだけ名前の末尾に「�」が並ぶ。この非対称は、テストの書き方そのものを見直す理由になりました。私自身、送信経路まで含めた往復を検証に入れるようになったのはこの実測の後です。
正規化がカウントを変える
もう一つ、事前の予想が外れた点があります。サーバー側で Unicode 正規化をかけると、文字数そのものが変わることです。
| 入力 | そのまま(書記素/UTF-16) | NFC 後 | NFKC 後 |
| が(濁点を分解した形) | 1 / 2 | 1 / 1 | 1 / 1 |
| ガ(半角カナ) | 1 / 2 | 1 / 2 | 1 / 1 |
| ㍑ | 1 / 1 | 1 / 1 | 4 / 4 |
| 👨👩👧👦 | 1 / 11 | 1 / 11 | 1 / 11 |
㍑ は NFKC 正規化で「リットル」へ展開されます。1文字が4文字になる。検索用に NFKC をかけている API に、20文字ちょうどの名前を送ると、サーバー側では 23 文字になって上限を超えます。クライアントには何の落ち度もありません。
半角カナも同じ構図です。NFC では変わらず、NFKC でだけ縮みます。つまり「どの正規化をかけるか」は表記ゆれの吸収方針であると同時に、文字数バリデーションの前提条件でもあるということです。
私はここで方針を一つ決めました。正規化とカウントは必ず同じ順序・同じ形式で行い、その順序を1か所にしか書かない。 検索用の正規化と保存用の正規化を分けたい場合でも、バリデーションが見るのは保存する側の形式に固定します。
数え方の正本を1か所に置く
対処は、数え方をモジュールに閉じ込め、クライアントとサーバーが同じものを読むようにすることでした。React Native 側と Cloudflare Workers 側の両方から import できる、依存のない TypeScript ファイルとして置いています。
// packages/shared/text-length.ts
// クライアント(React Native)とサーバー(Workers)の両方から読む唯一のカウント実装。
// ⚠️ ここ以外の場所で .length による文字数判定を書かないこと。
/** 保存時に適用する正規化形式。検索用の NFKC とは意図的に分けている。 */
const STORAGE_FORM: 'NFC' = 'NFC';
let segmenter: Intl.Segmenter | null = null;
let segmenterChecked = false;
function getSegmenter(): Intl.Segmenter | null {
if (segmenterChecked) return segmenter;
segmenterChecked = true;
try {
// Hermes を含め、実行環境によっては Intl.Segmenter が存在しません。
// 型ではなく実行時に確かめます。
if (typeof Intl !== 'undefined' && typeof (Intl as any).Segmenter === 'function') {
segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
}
} catch {
segmenter = null; // ロケール未対応などで throw する環境がある
}
return segmenter;
}
/** 上限判定・カウンタ表示・切り詰めのすべてが通る唯一の入口。 */
export function toGraphemes(input: string): string[] {
const s = input.normalize(STORAGE_FORM);
const seg = getSegmenter();
if (seg) return Array.from(seg.segment(s), (x) => x.segment);
return fallbackSegment(s);
}
export function countGraphemes(input: string): number {
return toGraphemes(input).length;
}
/** 書記素の途中で切らない切り詰め。破損した文字を作らない。 */
export function truncateGraphemes(input: string, limit: number): string {
if (limit <= 0) return '';
const g = toGraphemes(input);
if (g.length <= limit) return g.join('');
return g.slice(0, limit).join('');
}
export type LengthCheck =
| { ok: true; count: number }
| { ok: false; count: number; reason: 'too_long' | 'lone_surrogate' };
const LONE_SURROGATE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
/** サーバー側の検証もこの関数を呼ぶ。クライアントと判定が一致する。 */
export function checkLength(input: string, limit: number): LengthCheck {
// 壊れた文字が混じったまま保存すると、後段の全処理が汚染されます。
if (LONE_SURROGATE.test(input)) {
return { ok: false, count: 0, reason: 'lone_surrogate' };
}
const count = countGraphemes(input);
return count <= limit ? { ok: true, count } : { ok: false, count, reason: 'too_long' };
}
この truncateGraphemes を、同じ組み立て方の乱数文字列 3,000 件に対して上限20で流したところ、置換文字化・上限超過ともに 0 件 でした。素朴な slice で 75.6% が壊れていたのと同じ条件です。
normalize を toGraphemes の内側に置いた点が要です。呼び出し側が正規化を忘れる余地を残さないためで、前節で見た「正規化でカウントが変わる」問題を関数の外へ漏らさない形にしています。
Intl.Segmenter が無い環境への退避
Intl.Segmenter は、実行環境によっては存在しません。React Native の JavaScript エンジンや、ビルド設定によって Intl の実装範囲が変わるため、私は「あるはず」で書かず、必ず実行時に確かめる方針にしています。
無かった場合に [...s] へ落とすと、本稿の冒頭で見たとおり家族の絵文字が 7 と数えられ、上限20の入力欄が絵文字3個で埋まります。フォールバックとしては不十分です。そこで、主要な結合パターンだけを拾う簡易実装を用意しました。
// text-length.ts(続き)— Intl.Segmenter が無い環境向けの近似実装
const ZWJ = '\u200D'; // ZERO WIDTH JOINER
const VS16 = '\uFE0F'; // VARIATION SELECTOR-16
function isRegionalIndicator(cp: number): boolean {
return cp >= 0x1f1e6 && cp <= 0x1f1ff; // 🇦〜🇿(2つで1つの国旗)
}
function isSkinTone(cp: number): boolean {
return cp >= 0x1f3fb && cp <= 0x1f3ff; // 肌色修飾子
}
function isCombining(cp: number): boolean {
// 結合分音記号・ハングル字母・異体字セレクタなど、前の文字に付く範囲
return (
(cp >= 0x0300 && cp <= 0x036f) ||
(cp >= 0x1ab0 && cp <= 0x1aff) ||
(cp >= 0x1dc0 && cp <= 0x1dff) ||
(cp >= 0x20d0 && cp <= 0x20ff) ||
(cp >= 0xfe00 && cp <= 0xfe0f) ||
(cp >= 0x0e31 && cp <= 0x0e3a) ||
(cp >= 0x11a8 && cp <= 0x11ff) ||
(cp >= 0xe0100 && cp <= 0xe01ef)
);
}
function fallbackSegment(s: string): string[] {
const cps = Array.from(s);
const out: string[] = [];
let i = 0;
while (i < cps.length) {
let cluster = cps[i];
i++;
// 国旗は地域指示子2つで1つの書記素
if (isRegionalIndicator(cluster.codePointAt(0)!) &&
i < cps.length && isRegionalIndicator(cps[i].codePointAt(0)!)) {
cluster += cps[i];
i++;
out.push(cluster);
continue;
}
// 後続の結合要素・肌色・VS16・ZWJ連結を吸収する
while (i < cps.length) {
const next = cps[i];
const cp = next.codePointAt(0)!;
if (next === VS16 || isSkinTone(cp) || isCombining(cp)) {
cluster += next;
i++;
continue;
}
if (next === ZWJ && i + 1 < cps.length) {
cluster += next + cps[i + 1];
i += 2;
continue;
}
break;
}
out.push(cluster);
}
return out;
}
この近似は完全ではありません。Unicode の書記素クラスタ規則はもっと細かく、たとえばハングルの字母結合や一部のインド系文字では取りこぼします。それでも、絵文字と結合文字という実務上の大半を吸収できれば、上限判定が体感とずれる場面はほぼ消えます。
私はここを「正しさ」ではなく「劣化の度合い」で選びました。Intl.Segmenter があればそれを使い、無ければ精度は落ちるが方向性は同じ実装に落ちる。[...s] へ落ちる設計との違いは、劣化したときに何が起きるかが読めるかどうかです。
速さは、この判断の材料にならない
Intl.Segmenter は重いから入力のたびに呼ぶべきではない、という判断を私は最初にしていました。実際に測ってみると、これは根拠がありませんでした。
| 実装 | 10,000回の合計 | 1回あたり |
.length | 2.1 ms | 0.21 µs |
[...s].length | 7.6 ms | 0.76 µs |
| 簡易正規表現による分割 | 45.5 ms | 4.55 µs |
Intl.Segmenter | 116.5 ms | 11.65 µs |
比率で見れば Intl.Segmenter は .length の約55倍です。数字だけを見ると避けたくなります。
ところが絶対値で見ると、1回あたり 11.65 µs。40書記素の文字列を1回数えるコストを別途測ると 28.7 µs で、これは 60fps の1フレーム予算 16.7 ms に対して 0.172% にあたります。1文字打つたびに数え直しても、フレームを1枚も落としません。
比率で判断すると誤り、絶対値と予算で判断すると正解が変わる。パフォーマンスを理由に正しさを捨てる判断は、こういう場面ほど検算しておくべきだと思い知りました。私はこの結果を見て、入力のたびに countGraphemes を呼ぶ実装へ迷わず倒しています。
なお Intl.Segmenter のインスタンス生成自体はコストがあるため、上のコードではモジュールスコープで1つだけ作って使い回しています。呼ぶたびに new する実装では、この測定値は当てになりません。
保存する側とのすり合わせ
クライアントとサーバーで関数を共有しても、その先のデータベースが別の単位で数えていれば同じことが起きます。ここは手順として固定しました。
- カラムの上限を「書記素数の上限 × 最悪ケースのバイト数」で確保します。家族の絵文字が 25 バイトでしたので、書記素20文字の名前なら 500 バイト以上を見ておきます。文字数と同じ数字をカラム長に書くのが、最も踏みやすい落とし穴でした。
- API 層のバリデーションを
checkLength に一本化します。フレームワークが提供する maxLength 相当のバリデータは、ほぼ確実に UTF-16 か バイト数で数えているため、文字数の判定には使いません。
- 表示上のカウンタも同じ関数から出します。「20 / 20」と出しておいて保存が失敗する状態を、構造として作れないようにします。
- 保存前に孤立サロゲートを弾きます。既に壊れた文字が入っている行は、後からどう直しても復元できません。入口で止めるのが唯一の対処になります。
- 既存データの棚卸しをします。私の場合、過去に
slice で切っていた時期の行が残っていました。checkLength を全行に流して、lone_surrogate を返す行だけを抽出しています。
TextInput の maxLength については、外すという判断をしました。あれは UTF-16 コードユニットで効くため、書記素で数えるカウンタと必ずずれます。代わりに onChangeText の中で truncateGraphemes を通す形です。
// NameField.tsx — 入力欄側。maxLength は使わず、共有関数だけを通す
import { useCallback, useState } from 'react';
import { Text, TextInput, View } from 'react-native';
import { countGraphemes, truncateGraphemes } from '@shared/text-length';
const LIMIT = 20;
export function NameField({ initial = '' }: { initial?: string }) {
const [value, setValue] = useState(initial);
const count = countGraphemes(value);
const onChangeText = useCallback((next: string) => {
// maxLength に頼らず、書記素単位で切る。破損した文字を state に入れない。
setValue(next.length > value.length ? truncateGraphemes(next, LIMIT) : next);
}, [value.length]);
return (
<View>
<TextInput
value={value}
onChangeText={onChangeText}
// maxLength={LIMIT} は書かない(UTF-16 単位で効いてしまうため)
accessibilityLabel="表示名"
/>
<Text>{count} / {LIMIT}</Text>
</View>
);
}
一点だけ注意があります。日本語入力の変換途中に onChangeText が発火する環境では、確定前の文字列を切り詰めると変換が壊れることがあります。入力中に文字が消えたりカーソルが飛んだりする症状に心当たりがあれば、RorkアプリのTextInputで文字が消える・カーソルが飛ぶ問題を3パターンで直すも併せて確認していただければと思います。切り詰めを確定後に限定する分岐が必要な場面があります。
導入の順序
一度にすべてを揃える必要はありませんでした。効果の出た順に並べます。
- 共有モジュールを1つ作る(半日)。
countGraphemes と truncateGraphemes だけで構いません。この時点でクライアントとサーバーの食い違いは消えます。
- サーバー側のバリデーションを差し替える(1〜2時間)。ここを先にやると、既存クライアントからの保存が通るようになり、レビューの報告が止まります。
- 入力欄の
maxLength を外す(30分)。表示カウンタを共有関数に切り替えます。
- 孤立サロゲートの検査を入口に足す(1時間)。既存データの棚卸しはこの後で構いません。
- 送信経路まで含めた往復テストを書く(半日)。
JSON.stringify だけでは 0件、UTF-8 まで通すと 36.1% という差が、このテストの価値です。
Intl.Segmenter のフォールバックは、最後に回して問題ありませんでした。実際に落ちる環境に当たるまでは、実行時の存在確認だけ入れておけば十分です。
数え方を1か所に決める。書いてしまえば当たり前のことですが、当たり前だからこそ、誰も設計対象として扱っていませんでした。多言語まわりの判定は他にも同じ構図を持つものがあり、たとえば数量表現についてはCLDR複数形カテゴリとIntl.PluralRulesで数量表現を設計するで扱っています。
一件のレビューから始まった調べものでしたが、自分のコードが自分のコードを否定していた理由が分かったときは、少し胸が軽くなりました。同じ食い違いに心当たりのある方の手がかりになれば幸いです。お読みいただきありがとうございました。