~/gtn74

$ cat posts/20251222-cydtkd.mdx

[JavaScript] .length を使った文字カウントとサロゲートペア

2025-12-22 · #tech #javascript

先々月くらいに現場で学び直したことについて書きます。

前提

利用してる技術

既存のフォームに名前の TextField を追加するチケットでした。

他コンポーネントで使っていたようなスキーマを少し書き換えて追加で実装しました。

下みたいなスキーマ定義。

const schema = yup.object({
  name: yup
    .string()
    .min(2, "名前は2文字以上で入力してください")
    .max(20, "名前は20文字以内で入力してください")
    .required("名前は必須です"),
  age: yup
    .string()
    .min(0, "年齢は0以上で入力してください")
    .max(120, "年齢は120以下で入力してください")
    .required("年齢は必須です"),
});

テストは自身で画面を動かして設計書通りの挙動になっているか確認だけで、「この文字でテストしといて」みたいなものはありませんでした。

発生したこと

やたらとモックサーバーに「𰻞 𰻞」というユーザー名が存在することに違和感を覚えたため、テストしてみたところ、min(2) を設定しているにもかかわらず「𰻞」1 文字で送信できてしまいました。

調べようと思った理由

  • yup.object({name: yup.string().min(2, "名前は2文字以上で入力してください")});で 2 文字未満を弾くようにしてるはずなのに、なんで通過するのかわからなかった。

Claude 先生に聞いた。

回答

  1. yup の min はString.prototype.lengthで文字カウントしてる。
  2. UTF-16 のコードユニット数をカウントするためサロゲートペアが 2 としてカウントしている。

1. yup の min はString.prototype.lengthで文字カウントしてる。について

yup のstring.tsを見てみました。

確かに、return value!.lengthを使っているようでした。

yupのstring.tsのmin実装。return value!.length を使っている

"𰻞".lengthを確かめてなかったので、コンソールログで確認したところ 2 を返しました。

console.log("𰻞".length); // 2

1 を調べて分かったこと

"𰻞".lengthは、2 文字としてカウントされるらしい。

UTF-16 のコードユニット数をカウントするためサロゲートペアが 2 としてカウントしている。

次にJavaScript の文字コードについて jsprimer を確認しました。

JavaScript は、Unicode を採用してて、エンコードには UTF-16 を取り入れているようです。

つまり、渡ってきた文字情報(Unicode)を 16 進数でエンコードして、JavaScript 側で利用していると言うことなのだろうと思います。

試しに「あ」を Unicode でバラして、16 進数でエンコードすると、3042という Code Unit で出力されました。

// 引用:https://jsprimer.net/basic/string-unicode/
function convertCodeUnits(str: string) {
  const codeUnits = [];
  for (let i = 0; i < str.length; i++) {
    codeUnits.push(str.charCodeAt(i).toString(16));
  }
  return codeUnits;
}
 
// !あ
const str2 = "あ";
const a = convertCodeUnits(str2);
console.log(a);
console.log("\u{3042}");

※Code Unit:Unicode を 16 ビット単位でエンコードしたときの基本単位

「𰻞」も同じようにエンコードしてみました。

// !𰻞
const str = "𰻞";
const byan = convertCodeUnits(str);
console.log(byan);
console.log("𰻞".codePointAt(0)?.toString(16));
console.log("\u{d883}\u{dede}");

「𰻞」は 1 文字なのに、d883、dedeの 2 つの Code Unit が出力されました。

つまり「𰻞」が 1 文字なのに、2 文字カウントされる原因は、𰻞 は、Code Unit、2 つ分で成り立っているからでした。

このように 2 つの Code Unit で 1 文字を表現する仕組みをサロゲートペアと言うらしいです。

2 を調べて分かったこと

JavaScript の内部では、16 進数の Code Unit ベースで文字をカウントしている。

「𰻞」は、Code Unit で表現しようとすると、1 文字分の UniCode で表現できない。

2 文字分の Code Unit を使って 1 文字の「𰻞」を認識している。

yup の Issue でのやり取りを確認。

絵文字もサロゲートペアを使って表現しているため、1 つの絵文字を 2 つの Code Unit で表現しているようです。(全ての絵文字がサロゲートペアを使って表現してるわけではなさそう。)

この件について yup で Issue があったので、さささっと目を通してみました。

yup issues #1409

さささっとみた感じ、Code Unit での文字カウントは JavaScript の言語仕様なので、ライブラリが振る舞いを変えてしまうと混乱を招いてしまう。みたいなニュアンスの話をしているのかなと思いました。

最後に

yup.string().min(2)で「𰻞」1 文字が通ってしまう現象をきっかけに、JavaScript の length や文字カウントについて少し学ぶことができました。

現場では、yup の API に手を加えていたため、最初はそこに問題があるのではないか 🤔 と時間を溶かしていました。 他のフォームでは、名前のテキストフィールドに姓名の間にスペースを 1 文字空ける仕様になっていたため、このようなことは発生しなかったようでした。

$