対象読者
この記事は、HTML フォームの maxlength / minlength を使って文字数制限を書いている人、絵文字やサロゲートペアを含む入力欄の文字数制限が気になったことがある人、htmx のように HTML 属性で振る舞いや制約を宣言する設計に関心がある人を対象にしています。
特に、maxlength="40" を「40文字以内」と説明してよいのか、コードポイント単位の制約を HTML にどう書けばよいのか、独自属性を data-* にするべきか、といった点を整理したい人向けです。
はじめに:maxlength の代替属性を作りたくなった
HTML フォームで文字数制限を書くとき、まず思い浮かぶのは maxlength / minlength です。
<input name="display_name" maxlength="40">
このコードは、見た目には「表示名は40文字以内」と読めます。
しかし、HTML の maxlength / minlength は、Unicode のコードポイント数ではなく UTF-16 コード単位で数えます。
つまり、次のような文字では直感とずれることがあります。
"abc".length // 3
"😀".length // 2
"𠮷".length // 2
😀 や 𠮷 は、JavaScript の length では 2 になります。これは JavaScript の文字列長が UTF-16 コード単位で数えられるためです。
では、コードポイント単位で「40文字以内」と扱いたい場合はどう書けばよいのでしょうか。
最初は、htmx の hx-post のように、独自の属性を作れないかと考えました。
<input constraint-max-codepoints="40">
しかし、プロジェクト独自の属性を勝手に増やすと、HTML 標準、他ライブラリ、将来の仕様、HTML バリデータなどと衝突する可能性があります。
そこでこの記事では、maxlength / minlength の代替として、コードポイント単位の文字数制約を HTML にどう書くかを考えます。
最終的には、次のような形を目指します。
<input
name="display_name"
required
data-constraint-max-codepoints="40"
title="表示名は40文字以内で入力してください"
>
maxlength / minlength は UTF-16 コード単位
まず、前提を整理します。
maxlength / minlength は便利な標準属性です。ただし、ここでの長さは「人間が見た文字数」ではありません。
<input maxlength="4">
この 4 は、UTF-16 コード単位の数です。
JavaScript で見ると、次のようになります。
"abcd".length // 4
"あいうえ".length // 4
"😀😀".length // 4
"𠮷𠮷".length // 4
😀😀 は見た目には2文字ですが、UTF-16 コード単位では4です。
つまり、maxlength="4" は、ユーザーに対して「4文字以内」と説明したい場合に、常に自然な制約になるとは限りません。
もちろん、maxlength が間違っているわけではありません。仕様としては一貫しています。問題は、length という名前が「文字数」のように読めてしまうことです。
pattern でコードポイント単位に近づける
単純にコードポイント単位の上限を指定したいだけなら、まず pattern が候補になります。
<input
name="display_name"
required
pattern=".{1,40}"
title="表示名は40文字以内で入力してください"
>
pattern は値全体に一致する必要があるため、pattern=".{1,40}" は「1〜40個の任意の文字」という制約として扱えます。
maxlength と違い、現在の pattern は Unicode 対応の JavaScript 正規表現として扱われるため、サロゲートペアを含む文字をコードポイント単位に近い形で扱えます。
たとえば、次のような目的なら pattern で十分な場合があります。
<input
name="nickname"
required
pattern=".{1,20}"
title="ニックネームは20文字以内で入力してください"
>
ただし、pattern にも限界があります。
まず、pattern は正規表現です。制約の意味が複雑になると、HTML を読んだだけでは意図が分かりにくくなります。
また、エラーメッセージを細かく出したい場合にも限界があります。
たとえば、
20文字以内で入力してください。現在23文字です。
のように現在の文字数を含めたメッセージを出したい場合、pattern だけでは難しくなります。
その場合は、JavaScript で Constraint Validation API に接続したほうが扱いやすくなります。
maxlength / minlength の代わりになる名前を付けるむずかしさ
ここで、別の問題が出てきます。
maxlength / minlength の代替属性を作りたいとして、何という名前にすればよいのでしょうか。
標準属性はとても短いです。
<input maxlength="40">
<input minlength="1">
一方で、正確に書こうとすると名前が長くなります。
<input data-constraint-max-codepoints="40">
<input data-constraint-min-codepoints="1">
これはかなり長いです。
しかし、短くしようとすると、今度は意味が曖昧になります。
<input data-max="40">
<input data-max-length="40">
<input data-max-chars="40">
<input data-max-codepoints="40">
max だけでは、何の最大値か分かりません。
max-length は maxlength と似ていますが、結局「length が何を数えるのか」が曖昧です。
max-chars も危険です。char という言葉は便利ですが、次のどれを指しているのかがはっきりしません。
UTF-16 コード単位
Unicode コードポイント
書記素クラスター
ユーザーが見た1文字
この問題は、JavaScript の length と似ています。
value.length は短くて便利です。しかし、それが返すのは人間が感じる文字数ではなく UTF-16 コード単位です。
つまり、maxlength / minlength の問題が放置されやすい理由のひとつは、代わりになる短くて誤解の少ない名前がないことだと思います。
正確にしようとすると長くなる。短くしようとすると曖昧になる。
この落差があります。
そのため、この記事では短さよりも、読んだときの判断コストを下げることを優先して、次の名前を使います。
data-constraint-max-codepoints
data-constraint-min-codepoints
長いですが、次のことが属性名から分かります。
data- HTML標準のカスタムデータ属性
constraint- 入力値が満たすべき制約
max/min- 最大値または最小値
codepoints 数える単位はコードポイント
コードは書く時間より、読まれる時間のほうが長くなりがちです。
特に今回のように、既存の maxlength / minlength を補うリファクタリング的な機能では、属性名に説明を含める価値があります。
htmx 風の独自属性は衝突リスクがある
htmx では、次のように hx-* 属性を使って HTTP リクエストを宣言できます。
<button hx-post="/profile" hx-target="#result">
保存
</button>
この書き方を見ると、自分たちのアプリでも次のように書きたくなります。
<input constraint-max-codepoints="40">
JavaScript から読むこと自体はできます。
const max = input.getAttribute("constraint-max-codepoints");
しかし、これは標準的な独自データ属性ではありません。
プロジェクト内では動いても、次のような懸念があります。
将来の HTML 標準属性と衝突する可能性
他ライブラリの属性名と衝突する可能性
HTML バリデータや静的解析で警告される可能性
テンプレートエンジンやコンポーネント処理と相性が悪くなる可能性
その属性がライブラリ由来なのかアプリ固有なのか分かりにくい
htmx の hx-* は、htmx というライブラリが責任を持って管理している名前空間です。
一方で、アプリケーション内の小さな独自バリデーションのために、constraint-* のような接頭辞を勝手に作ると、その名前空間の責任を自分たちで持つことになります。
そのため、アプリ内の独自制約であれば、基本的には data-* を使うほうが安全です。
<input data-constraint-max-codepoints="40">
これは長くなりますが、HTML 標準のカスタムデータ属性として扱えます。
data-constraint-max-codepoints を Constraint Validation API に接続する
data-* 属性を書いただけでは、ブラウザのバリデーションにはなりません。
独自属性を HTML フォームの制約として扱うには、setCustomValidity() に接続します。
まず HTML です。
<form id="profile-form">
<label>
表示名
<input
name="display_name"
required
data-constraint-max-codepoints="40"
title="表示名は40文字以内で入力してください"
>
</label>
<button>保存</button>
</form>
コードポイント数は、文字列 iterator を使って数えられます。
function countCodepoints(value) {
return [...value].length;
}
比較すると、違いが分かります。
"😀".length // 2
[..."😀"].length // 1
"𠮷".length // 2
[..."𠮷"].length // 1
次に、data-constraint-max-codepoints を読むバリデーション関数を作ります。
function validateMaxCodepointsConstraint(input) {
const max = Number(input.dataset.constraintMaxCodepoints);
if (!Number.isFinite(max) || max < 0) {
input.setCustomValidity("");
return;
}
const count = countCodepoints(input.value);
input.setCustomValidity(
count > max
? `${max}文字以内で入力してください。現在${count}文字です。`
: ""
);
}
入力時に検証します。
document.addEventListener("input", (event) => {
const input = event.target;
if (input.matches("[data-constraint-max-codepoints]")) {
validateMaxCodepointsConstraint(input);
}
});
送信直前にも検証しておくと、初期値入りのフォームやスクリプトによる値変更にも対応しやすくなります。
document.querySelector("#profile-form").addEventListener("submit", (event) => {
const form = event.currentTarget;
for (const input of form.querySelectorAll("[data-constraint-max-codepoints]")) {
validateMaxCodepointsConstraint(input);
}
if (!form.reportValidity()) {
event.preventDefault();
}
});
これで、独自属性を使いながら、ブラウザ標準の Constraint Validation API に接続できます。
htmx と組み合わせる
htmx を使う場合も、考え方は同じです。
htmx 専用のバリデーション機構を作るのではなく、HTML 標準の Constraint Validation API に接続します。
<form hx-post="/profile" hx-target="#result">
<label>
表示名
<input
name="display_name"
required
data-constraint-max-codepoints="40"
title="表示名は40文字以内で入力してください"
>
</label>
<button>保存</button>
</form>
<div id="result"></div>
htmx には、検証前に発火する htmx:validation:validate イベントがあります。ここで setCustomValidity() を呼び出します。
function validateCodepointConstraints(root) {
for (const input of root.querySelectorAll("[data-constraint-max-codepoints]")) {
validateMaxCodepointsConstraint(input);
}
}
document.body.addEventListener("htmx:validation:validate", (event) => {
const root = event.detail.elt.closest("form") ?? event.detail.elt;
validateCodepointConstraints(root);
});
これで、htmx の送信前検証に独自制約を乗せられます。
ポイントは、htmx のためだけに特殊な仕組みを作らないことです。
HTML に制約を書く
JavaScript で Constraint Validation API に接続する
htmx は送信前にその検証結果を使う
という分担にすると、htmx を使わないフォームにも流用しやすくなります。
minlength の代替も同じ考え方で作れる
maxlength の代替として data-constraint-max-codepoints を作ったなら、minlength の代替も同じ考え方で作れます。
HTML はこうです。
<input
name="display_name"
required
data-constraint-min-codepoints="1"
data-constraint-max-codepoints="40"
>
JavaScript 側では、最小値も検証します。
function validateCodepointsConstraint(input) {
const value = input.value;
const count = countCodepoints(value);
const min = Number(input.dataset.constraintMinCodepoints);
const max = Number(input.dataset.constraintMaxCodepoints);
if (Number.isFinite(min) && count < min) {
input.setCustomValidity(
`${min}文字以上で入力してください。現在${count}文字です。`
);
return;
}
if (Number.isFinite(max) && count > max) {
input.setCustomValidity(
`${max}文字以内で入力してください。現在${count}文字です。`
);
return;
}
input.setCustomValidity("");
}
ただし、必須入力については required を使えばよいです。
<input required>
空文字を禁止するだけなら、独自の min-codepoints を使うより required のほうが自然です。
data-constraint-min-codepoints が必要になるのは、たとえば「5文字以上」のように、空文字禁止以上の意味を持つ場合です。
書記素クラスターは今回の主役にしない
Unicode の文字数を考えると、書記素クラスターも気になります。
たとえば、次のような文字列です。
🇯🇵
👨👩👧👦
が
これらは、見た目には1文字または1記号に見えても、内部的には複数のコードポイントから構成されることがあります。
JavaScript では Intl.Segmenter を使うことで、書記素クラスターに近い単位で数えられます。
const segmenter = new Intl.Segmenter("ja", {
granularity: "grapheme"
});
function countGraphemes(value) {
return [...segmenter.segment(value)].length;
}
ただし、この記事では書記素クラスターを主役にはしません。
理由は、今回の目的が maxlength / minlength の代替として、HTML にコードポイント単位の制約をどう表現するかだからです。
書記素クラスターは、ユーザーが見た目で感じる文字数に近い単位です。文字カウンターや UI 表示では有用です。
一方で、maxlength / minlength の代替として使うには、少し別の議論になります。
そのため、この記事では次の方針にします。
UTF-16 コード単位の標準属性:maxlength / minlength
コードポイント単位の代替:data-constraint-*-codepoints
見た目の文字数:必要になったら Intl.Segmenter を検討
最終形:長い属性名は読む時間を減らすために使う
最終的な例はこうです。
<form hx-post="/profile" hx-target="#result">
<label>
表示名
<input
name="display_name"
required
data-constraint-max-codepoints="40"
title="表示名は40文字以内で入力してください"
>
</label>
<button>保存</button>
</form>
<div id="result"></div>
data-constraint-max-codepoints は長いです。
しかし、次のような短い名前よりも意味が明確です。
<input data-max="40">
<input data-max-length="40">
<input data-max-chars="40">
<input data-max-codepoints="40">
今回の属性名には、次の情報が入っています。
data- 標準的なカスタムデータ属性
constraint- 入力値が満たすべき制約
max- 最大値
codepoints 数える単位
コードは書く時間より、読まれる時間のほうが長いです。
特に今回のように、既存の maxlength / minlength を補う機能では、短さよりも「読んだときに何をしているか分かること」を優先したほうがよいと思います。
rule という名前も候補になります。
<input data-rule="display-name">
しかし、この形だと読み手は「display-name ルールはどこに定義されているのか」と考える必要があります。
一方で、
<input data-constraint-max-codepoints="40">
なら、その場でかなり意味が閉じています。
これは、名前付きの業務ルールではなく、入力値が満たすべき制約そのものです。
まとめ
maxlength / minlength は便利ですが、数えている単位は UTF-16 コード単位です。
絵文字やサロゲートペアを含む文字列では、ユーザーが感じる文字数や Unicode のコードポイント数とずれることがあります。
コードポイント単位で制約したい場合、軽量な代替案としては pattern=".{1,40}" が使えます。
より細かくメッセージを出したい場合や、共通化したい場合は、data-* 属性と Constraint Validation API を組み合わせる方法があります。
この記事では、次の属性名を使いました。
data-constraint-max-codepoints="40"
data-constraint-min-codepoints="1"
長い名前ですが、maxlength / minlength の代替として何をしているのかが読みやすくなります。
また、htmx の hx-* のような独自接頭辞をアプリ内で真似ることもできますが、衝突リスクを考えると、独自バリデーションでは data-* に寄せるほうが安全です。
最終的な考え方は次のとおりです。
maxlength / minlength は UTF-16 コード単位
pattern はコードポイント単位の軽量な代替案
独自制約は data-* に書く
制約名には constraint を使うと意図が伝わりやすい
Constraint Validation API に接続してブラウザ標準の検証に乗せる
htmx では htmx:validation:validate に接続できる
短く書ける標準属性は便利です。
しかし、標準属性の意味と要件がずれる場合は、少し長くても、何をどの単位で制約しているのかを HTML に明示したほうが、あとで読む人の判断コストを下げられます。