自己紹介欄に「20 文字以内」と表示されているのに、笑顔の emoji を 20 個入力すると上限を超える。普通の漢字なら 20 個入力できる。text.length を [...text].length に変更すると笑顔の問題は解決しても、家族の emoji はまだ複数の文字として数えられる。

これは説明のための仮定です。ただし、考えるべき実装上の問題は明確です。画面に書いた「文字」を、計算できて説明もできる単位に落とし込む必要があります。 コードユニット、コードポイント、書記素クラスタ、バイトはそれぞれ違う問いに答えます。一つの length にまとめると、画面、API、保存先で基準が食い違います。

この記事では単位を整理したうえで、小さな検証関数を完成させます。表と JavaScript の主要な例は、2026-09-28 に Node.js v22.23.1、ICU 78.2、Unicode 17.0 で再実行しました。ブラウザーの IME は別の統合課題として扱います。Node での実験をブラウザー互換性の検証とはしていません。

1. 四つの長さは、それぞれ何を数えているか

UTF-16 コードユニット:JavaScript の文字列の単位

JavaScript の文字列は 16 ビットのコードユニットからなり、length はその数を返します。文字列の添字もこの単位を使います。根拠は ECMAScript の String 型の定義です。

今回の例では 谷 は一つ、😀 は二つのコードユニットを使います。後者はサロゲートペアで表現されるため、"😀".length は 2 です。これは emoji だけの話ではありません。拡張漢字の 𠮷 も 2 になります。「漢字なら length でよく、絵文字だけ特別扱いすればよい」という判断では、扱えない入力が残ります。

Unicode コードポイント:文字列を反復したときの要素

コードポイントは Unicode の符号空間にある番号と考えられます。😀 は U+1F600、𠮷 は U+20BB7 です。正しい Unicode テキストでは、JavaScript の文字列イテレーターがサロゲートペアをまとめて取り出すので、[...text].length はどちらも一つと数えます。この挙動は文字列イテレーターの仕様に定義されています。

それでも、読む人にとっての一文字が複数のコードポイントからできている場合があります。e\u0301 は e と結合アクセントです。👨‍👩‍👧‍👦 には四人分のコードポイントと三つのゼロ幅接合子が含まれます。コードポイント単位ならサロゲートペアは扱えても、こうした組み合わせの境界までは扱えません。

書記素クラスタ:読み書きするときの文字に近い単位

書記素クラスタは、複数のコードポイントを一つのまとまりとして区切ります。この記事では Intl.Segmenter の grapheme モードを使い、利用者が認識する文字の数に近い分割を得ます。ただし、フォントが描く字形とは別の概念で、一つのまとまりが画面上の一マスを占めるという意味でもありません。この近似と限界は Unicode UAX #29で説明されています。

自己紹介欄の「何文字まで」という基準には書記素クラスタを使い、表示幅はレイアウト側で扱うのが私の設計方針です。「一覧のタイトルを二行に収めたい」という要件なら、正しく数えてもまだフォント、文字サイズ、コンテナー幅、折り返しを考える必要があります。一つの文字数からあらゆる表示を予測しようとすると、要件が定まりません。

UTF-8 バイト:指定した符号化でのデータ量

TextEncoder は文字列を UTF-8 に符号化します。返されるバイト配列の長さが、そのテキストの UTF-8 バイト数です。仕様は Encoding Standardにあります。「何バイトか」と言うときは、符号化方式の名前も必要です。

これはテキスト欄の符号化後の容量を制限する用途に向きます。JavaScript オブジェクトが使うメモリー量や、HTTP リクエスト全体のサイズと同じではありません。JSON のフィールド名、引用符、エスケープされた内容には別の容量が必要なこともあります。それぞれ別に測定するべき指標です。

2. 同じ入力を四つの単位で測定する

測定結果のフィールド名に単位を入れます。最初に、対応する相手のないサロゲートコードユニットが含まれていないかも確認します。

const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const encoder = new TextEncoder();

function requireText(text) {
  if (typeof text !== 'string') throw new TypeError('Expected a string');
  if (!text.isWellFormed()) throw new TypeError('Unpaired UTF-16 surrogate');
}

function measureText(text) {
  requireText(text);
  let codePoints = 0;
  let graphemes = 0;
  for (const _ of text) codePoints++;
  for (const _ of segmenter.segment(text)) graphemes++;
  return {
    utf16Units: text.length,
    codePoints,
    graphemes,
    utf8Bytes: encoder.encode(text).length,
  };
}

今回の結果は次のとおりです。表の e\u0301 はエスケープで構成した二つのコードポイントを示しています。バックスラッシュなどをそのまま入力した文字列ではありません。

入力UTF-16 コードユニットコードポイント書記素クラスタUTF-8 バイト
ABC3333
谷米2226
𠮷2114
😀2114
e\u03012213
🇯🇵4218
👨‍👩‍👧‍👦117125

家族の emoji は、単位の違いを確認するよい反例です。この環境では書記素クラスタは一つですが、UTF-8 では 25 バイトです。「文字数の上限」だけでは「データ量の上限」を表現できません。カウンターに小さな数が表示されても、別の容量制限に達する入力はあります。

実験スクリプトの全文には、この表、以降の実装、境界を確認するアサーションを収録しています。保存して node text-length.mjs を実行すると再現できます。スクリプトは Intl.Segmenter と String.prototype.isWellFormed の有無を確認します。分割 API がないときにコードポイント数へ自動で切り替える処理は、ここで決めたルールを変更してしまうため入れていません。

3. 「20 文字、かつ 80 バイトまで」を実装する

まず、説明用の自己紹介欄のルールを明示します。文字数は書記素クラスタで数え、空白と句読点も含めます。UTF-8 では 80 バイトまで。空文字列は許可し、自動的な空白除去、正規化、切り詰めは行いません。20 と 80 はこの例のパラメーターで、すべてのサイトに勧める数値ではありません。必須入力かどうかは別の製品ルールです。

const BIO_LIMITS = Object.freeze({ maxGraphemes: 20, maxUtf8Bytes: 80 });

function validateBio(text) {
  if (typeof text !== 'string') {
    return { ok: false, errors: ['not-string'], counts: null };
  }
  if (!text.isWellFormed()) {
    return { ok: false, errors: ['ill-formed-unicode'], counts: null };
  }
  const counts = measureText(text);
  const errors = [];
  if (counts.graphemes > BIO_LIMITS.maxGraphemes) errors.push('too-many-graphemes');
  if (counts.utf8Bytes > BIO_LIMITS.maxUtf8Bytes) errors.push('too-many-utf8-bytes');
  return { ok: errors.length === 0, errors, counts };
}

文字数とバイト数のエラーを分けることで、呼び出し側は原因に合った説明を表示できます。次の境界は、実際にアサーションで確認しました。

入力書記素クラスタUTF-8 バイト結果
日 を 20 個2060許可
日 を 21 個2163文字数超過
😀 を 20 個2080許可。バイト上限ちょうど
👨‍👩‍👧‍👦 を 4 個4100バイト数超過
空文字列00許可

戻り値の構造は API の約束も明確にします。文字数超過なら文字を減らすよう案内できます。バイト数超過なら、複合的な文字も符号化後の容量を増やすことを説明する必要があります。この二つの制限を採用するなら、両方をヘルプに示すべきです。「4/20」と表示されているのに送信できない状態を、利用者に推測させてはいけません。

未対のサロゲートは別に扱います。たとえば '😀'.slice(0, 1) はサロゲートペアの半分を残します。TextEncoder が受け取る USVString への変換では、この値は置換文字になります。根拠は Web IDL の USVString 変換です。この例では拒否し、符号化時に内容が変わったのに原文を保存したつもりになる事態を避けます。入力を修復したいアプリケーションは、置換方針を別に定め、何が変わったかを明確にする必要があります。

これは短い自己紹介を対象にした、入力全体を走査する例です。処理速度は測定していません。大きなテキストを受け取れる API では、まずリクエスト本文の容量を制限し、その後でフィールドを検証する設計が必要です。どんな大きさの入力でも受信してからバイト数を数えるだけでは、受信過程を保護できません。性能の改善も、対象データを使った測定を根拠にするべきです。

4. 切り詰めるときにも単位を揃える

数え方を直しても、切り詰めに slice(0, max) を使えば問題が戻ります。今回の実験では、次の二つが異なる壊れ方をしました。

'😀'.slice(0, 1).isWellFormed(); // false:未対のサロゲートが残る
[...'👨‍👩‍👧‍👦'].slice(0, 1).join(''); // '👨':組み合わせが分かれる

書記素クラスタごとに先頭のまとまりを残せば、今回の例の境界を保てます。

function takeGraphemes(text, max) {
  requireText(text);
  if (!Number.isInteger(max) || max < 0) throw new RangeError('Invalid limit');
  let result = '';
  let count = 0;
  for (const { segment } of segmenter.segment(text)) {
    if (count === max) break;
    result += segment;
    count++;
  }
  return result;
}

// takeGraphemes('👨‍👩‍👧‍👦谷米', 1) === '👨‍👩‍👧‍👦'
// takeGraphemes('e\u0301x', 1) === 'e\u0301'

この補助関数が制限するのは書記素クラスタ数だけです。使った後もバイト数の検証は必要です。プレビューに省略記号を加えるなら、それを上限に含めるかも決めます。表示用の抜粋と原文の保存は別の操作です。私ならこの関数はプレビューに使い、フォーム送信ではエラーを返して、どう変更するかを利用者に委ねます。編集中に内容を黙って捨てる動作は採用しません。

5. Web ページではHTML 標準の制限と IME を確認する

<textarea maxlength="20"> を書くだけでは、前の書記素クラスタのルールを実現できません。HTML の maxlength は仕様上の文字列長を使い、Infra の文字列長はコードユニット単位です。HTML の入力長制限と Infra の文字列定義が根拠です。「家族の emoji を 20 個」とは別の条件になります。

書記素クラスタを基準にするなら、そのルールを自前のカウンターと送信時の検証で表現します。HTML 標準の maxlength を追加の制限として残す場合も、単位と数値を明確にし、説明されていない別の上限に先にぶつからないようにします。

日本語や中国語の IME では、文字の組み立て途中の状態もあります。compositionend は入力の組み立てが完了またはキャンセルされたときに発火します。説明は MDN のイベント文書にあります。私の統合方針は、組み立て中の値を保ち、終了後に表示を更新し、送信時に最終的な内容を検証することです。キーイベントのたびに、切り詰めた文字列で value を書き換えないようにします。

この部分は、対象ブラウザー、実際の IME、貼り付け、カーソル位置を変えた編集で検証する必要があります。Node のアサーションでは候補ウィンドウも、value の変更後にカーソルが飛ぶかどうかも確認できません。検証を分けて記録することで、まだ証拠のない範囲がわかります。

6. 単語数は、文字数とは別のルール

空白で区切った結果は空白区切りの断片であり、自動的に日本語や中国語の単語数にはなりません。同じ環境で 私は猫が好きです。 に次のコードを適用しました。

const text = '私は猫が好きです。';
const words = [...new Intl.Segmenter('ja', { granularity: 'word' }).segment(text)]
  .filter(part => part.isWordLike)
  .map(part => part.segment);
// ['私', 'は', '猫', 'が', '好き', 'です']

今回は六つの断片になりました。trim().split(/\s+/) なら一つのままで、同じ集計を表せません。単語境界や isWordLike の判定には実装やロケールの要因があります。根拠は ECMA-402 の分割規則です。原稿料や試験など、厳密な指標に用いる場合には、業務として受け入れる分割規則を定める必要があります。一度の実行結果を言語学上の唯一の答えとはできません。

7. 維持できる入力ルールとして残す

実際のシステムに組み込むときは、単位、容量上限、変換の順序を API の説明に記載し、フロントエンドとサーバーで境界のサンプルを共有します。普通の漢字、拡張漢字、結合アクセント、旗、接合子を含む emoji に加え、上限ちょうど、上限超過、未対のサロゲートも含めます。実行環境を変更したら、まず再実行し、結果の差にルールの移行が必要か判断します。

フロントエンドは早いフィードバックを担当し、受信側が最終的な検証を担当します。両方に「20」と書くだけでは足りません。一方がコードポイント、もう一方が書記素クラスタを数えていたら、食い違いが残ります。Intl.Segmenter の具体的な境界には実装による違いが許されています。ECMA-402 の境界決定を参照し、実行環境と結果を記録する必要があります。すべてのバージョンで同じと仮定はできません。

保存前に Unicode 正規化もするなら、変換と計数の順序を定め、最終的に保存する値に容量検証を適用します。この話は正規化と検索キーの記事で続けます。ここでの検証関数は原文をそのまま扱い、二つの方針を一つの関数に混ぜていません。

数える単位を選ぶことは、入力制限の出発点です。説明できるエラー、文字の境界、画面とサーバーの一致した約束、再実行できる反例まで揃えることで、「20 文字以内」が検証可能なシステムの動作になります。