3MIKAN
仮想通貨直コン

JavaScriptの文字数とバイト数は違う:UTF-16・UTF-8・絵文字をローカルで確認する

JavaScriptの文字数とUTF-8のバイト数が違う理由を、絵文字や結合文字で確認します。UTF-16、コードポイント、書記素クラスタ、正規化、壊さずに短くする処理を解説します。

3MIKANのキャラクターが同じ形の飾りを複数の道具で測り、数え方の違いを確かめる場面

「32文字以下のはずなのに、保存先の32バイト制限に引っかかる」。反対に、「1文字だけ残したつもりなのに、絵文字が壊れる」。こうした問題では、文字列の内容より先に、何を1として数えているかを確認します。

JavaScriptのlength、Unicodeのコードポイント、見た目に近いまとまり、UTF-8のバイト数は、同じ値になりません。この記事ではNode.jsの標準機能だけで違いを再現します。ウォレット、APIキー、外部サービス、ブラウザ操作は不要です。

掲載例はNode.js 22以降で実行できます。各節のコードをexample.mjsへ保存し、同じ節で使う関数定義と呼び出しを合わせてからnode example.mjsを実行してください。再利用できる関数はtext-length.mjsから取得できます。

「文字数」を四つの物差しに分ける

JavaScriptの文字列のlengthはUTF-16のコード単位数です。1コード単位は16ビットで、基本多言語面(BMP:U+0000〜U+FFFF)の外側にあるコードポイントはサロゲートペアの2単位で表現されます。[...text]で数えられるのは、これとは別のコードポイント単位です。1

さらに、アクセント付きの文字や複数の絵文字が結合した表示では、複数のコードポイントが一つのまとまりになります。このまとまりを扱うのが書記素クラスタです。Intl.Segmentergranularity: 'grapheme'でその境界を求めます。ただし、書記素数をフォント上の字形数や表示幅の完全な測定と同一視しません。23

最後のUTF-8バイト数は、実際にUTF-8へ符号化したデータの長さです。TextEncoderはUTF-8のバイト列を返します。4

UTF-16コード単位 コードポイント 書記素クラスタ UTF-8バイト
A 1 1 1 1
1 1 1 3
😀 2 1 1 4
é(U+00E9) 1 1 1 2
e + U+0301 2 2 1 3
👨‍👩‍👧‍👦 11 7 1 25

上表はNode.js v22.16.0、ICU 77.1、Unicode 16.0で実行確認した値です。書記素分割の結果は実装が使うUnicodeデータにも依存するため、再現用にはNodeだけでなくICUとUnicodeのバージョンも残します。

四種類の長さを一度に出してみる

次のコードは、そのままNode.jsで実行できます。

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

function measureText(text) {
  if (typeof text !== 'string') throw new TypeError('expected string');
  if (!text.isWellFormed()) throw new TypeError('unpaired surrogate');
  return {
    codeUnits: text.length,
    codePoints: [...text].length,
    graphemes: [...segmenter.segment(text)].length,
    utf8Bytes: encoder.encode(text).length,
  };
}

console.log(measureText('😀'));
// { codeUnits: 2, codePoints: 1, graphemes: 1, utf8Bytes: 4 }

コード内でどれか一つをlengthという曖昧な名前にまとめず、codeUnitsgraphemesutf8Bytesのように単位を変数名へ残しています。入力欄の見た目の制約と、保存先のバイト制限を同じ値で判定しないためです。

たとえば表示名を「20書記素まで」とする規則と、送信データを「64 UTF-8バイトまで」とする規則は別々に判定します。前者を満たしても、後者を満たすとは限りません。

同じ見た目でも、バイト列が一致するとは限らない

éは、単独のU+00E9でも、eと結合アクセントU+0301の組でも表せます。次のコードでは、文字列もバイト列も異なります。

const a = '\u00e9';
const b = 'e\u0301';

console.log(a === b);                     // false
console.log(new TextEncoder().encode(a)); // Uint8Array(2) [195, 169]
console.log(new TextEncoder().encode(b)); // Uint8Array(3) [101, 204, 129]
console.log(b.normalize('NFC') === a);     // true

Unicode正規化は、定義された関係にある表現を一定の形式へ揃える処理です。NFCとNFKCなどの形式には意味の違いがあり、何でも同じ文字列へまとめる機能ではありません。5

検索や表示名の照合で正規化することはあっても、受け取ったバイト列の保存やハッシュ計算の直前で勝手に文字列を変更しないようにします。送信側と受信側で正規化の時点が違えば、同じ表示でも異なるデータを対象にできます。

「正規化すれば見た目が似た名前をすべて見抜ける」という意味でもありません。正規化、文字の同形性、本人性の確認は別の課題です。ここでは名前の安全性を判定するのではなく、文字列とバイト列の境界だけを検証します。

sliceでサロゲートペアを切ると文字列が壊れる

JavaScriptのsliceはコード単位のインデックスで切り出します。😀は2コード単位なので、1単位だけ残すと不対サロゲートになります。1

const broken = '😀'.slice(0, 1);
console.log(broken.isWellFormed()); // false
console.log(new TextEncoder().encode(broken));
// Uint8Array(3) [239, 191, 189]

この3バイトは置換文字U+FFFDのUTF-8表現です。TextEncoderが元の絵文字を途中まで保存した結果ではありません。入力がUnicodeスカラー値として扱われる過程で、不対サロゲートが置換されます。4

そのため、この記事のmeasureTextisWellFormed()を先に確認し、意図しない置換を黙って受け入れません。置換して表示を継続することが適切な用途もありますが、元の入力を正確に保存する目的とは分けます。

バイト制限内で短くするなら、分割の単位も決める

UTF-8のバイト配列を任意の位置で切ると、複数バイトで表されるコードポイントの途中を切る可能性があります。コードポイントを壊さないだけでは、結合アクセントやZWJ絵文字のまとまりを壊す場合も残ります。43

次の例は、書記素のまとまりを保ったまま、UTF-8の予算内に収まる先頭部分を返します。先ほどのencodersegmenterを使います。

function fitUtf8Prefix(text, maxBytes) {
  if (typeof text !== 'string') throw new TypeError('expected string');
  if (!text.isWellFormed()) throw new TypeError('unpaired surrogate');
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
    throw new RangeError('invalid byte budget');
  }
  let result = '';
  let bytes = 0;
  for (const { segment } of segmenter.segment(text)) {
    const width = encoder.encode(segment).length;
    if (bytes + width > maxBytes) break;
    result += segment;
    bytes += width;
  }
  return result;
}

console.log(fitUtf8Prefix('あ😀B', 6)); // あ
console.log(fitUtf8Prefix('あ😀B', 7)); // あ😀
console.log(fitUtf8Prefix('あ😀B', 8)); // あ😀B

6バイトの予算では、最初のの3バイトしか入りません。次の😀を加えると7バイトになるからです。収まらない書記素を飛ばして後のBを入れる処理ではなく、あくまで入力の先頭部分を返します。

家族絵文字1つが25バイトある場合、24バイトの予算では空文字列を返します。このような結果を許すか、入力をエラーにするかもアプリ側の仕様です。識別名や署名対象を短くする用途に、この表示用の切り詰めを自動適用してはいけません。

バイト列の長さと、ABI符号化後の長さも同じではない

Solidityのstringbytes(s).lengthで見ると、バイト単位の長さを扱います。JavaScriptのUTF-16コード単位数と置き換えることはできません。UTF-8として同じ内容を渡す場合でも、その後にABIの動的型として符号化すると、オフセット、長さ、パディングなどが加わります。67

したがって、「元の文字列が3バイトだからcalldataも3バイト増える」とは限りません。文字列の長さ、UTF-8の長さ、ABIの符号化サイズを別々に記録します。ABIそのものの構造は関数識別子・入力データ・イベントログの読み方で確認できます。

本記事は文字列の単位を切り分ける説明で、Solidityのコンパイル、デプロイ、ガス計測は実施していません。

まとめ

文字列の長さを扱う前に、コード単位、コードポイント、書記素、UTF-8バイトのどれを制限するかを決めます。

さらに、正規化するか、不対サロゲートを拒否するか、長すぎる入力を拒否するか表示だけ短くするかを明示します。ASCII、日本語、BMP外の絵文字、結合文字、ZWJ、空文字列を並べると、英数字だけのテストでは見えなかった境界を確認できます。

Footnotes

  1. ECMAScript:文字列処理。2026-09-05確認。 2

  2. ECMA-402:Intl.Segmenter。2026-09-05確認。

  3. Unicode Standard Annex #29:Text Segmentation。2026-09-05確認。 2

  4. WHATWG Encoding Standard:TextEncoder。2026-09-05確認。 2 3

  5. Unicode Standard Annex #15:Normalization Forms。2026-09-05確認。

  6. Solidity:bytesとstring。2026-09-05確認。

  7. Solidity Contract ABI Specification。2026-09-05確認。

確認した一次情報