input の属性の maxlength で文字数制限してたら 4 byte 絵文字は 2 文字分扱いだった
JavaScript の length に合わされてるみたい

絵文字が使われることが増えてきてるし 日本語を 1 文字扱いするところだと絵文字も 1 文字扱いのほうが自然だからそうしたいけど そうなると maxlength が使えず JavaScript で制御するしかなさそう

それに絵文字も複雑化していて

(人っぽい絵文字(4byte)) + (U+200D) + (♂or♀)

で性別違いの派生版が出せるものがあったりと 見た目と内部文字数が一致しない

"🧝‍♀".split("")
// (4) ['\uD83E', '\uDDDD', '‍', '♀']

[..."🧝‍♀"]
// (3) ['🧝', '‍', '♀']

[..."🧝‍♀"].map(x => x.codePointAt().toString(16))
// (3) ['1f9dd', '200d', '2640']

もっと長いものだと

"👨‍👩‍👧‍👦".split("")
// (11) ['\uD83D', '\uDC68', '‍', '\uD83D', '\uDC69', '‍', '\uD83D', '\uDC67', '‍', '\uD83D', '\uDC66']

[..."👨‍👩‍👧‍👦"]
// (7) ['👨', '‍', '👩', '‍', '👧', '‍', '👦']

[..."👨‍👩‍👧‍👦"].map(x => x.codePointAt().toString(16))
// (7) ['1f468', '200d', '1f469', '200d', '1f467', '200d', '1f466']

これは文字幅も 3, 4 文字分くらいになってる

U+200D があれば前後をまとめて一つとみなせばいいので自分で計算できなくなさそうだけど 無効な結合の場合もある
"あ\u200dい" みたいなケース
さすがにこういうのの判断を自分でしたくない
Intl.Segmenter でセグメント分けすると正常な絵文字結合なら 1 セグメントとしてくれる
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter

[...new Intl.Segmenter().segment("abc あ\u200dい🦝🧝‍♀👨‍👩‍👧‍👦")]
// (9) [...]
// 0: {segment: 'a', index: 0, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 1: {segment: 'b', index: 1, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 2: {segment: 'c', index: 2, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 3: {segment: ' ', index: 3, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 4: {segment: 'あ‍', index: 4, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 5: {segment: 'い', index: 6, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 6: {segment: '🦝', index: 7, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 7: {segment: '🧝‍♀', index: 9, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}
// 8: {segment: '👨‍👩‍👧‍👦', index: 13, input: 'abc あ‍い🦝🧝‍♀👨‍👩‍👧‍👦'}

無効な結合文字は除外される
セグメント数を数えれば絵文字を 1 文字とした文字数になる

けどそこまでするものなのかなという気持ちもある
変な工夫をせず絵文字はそういうものと考えて単純に length 換算でいいのかも

DB だとどうなるか気になったので 一応 PostgreSQL で実行してみたら
絵文字単体は 4byte でも 1 文字扱い
結合文字が入ると別文字扱いで JavaScript で [..."(絵文字)"].length した場合と同じ

これでいいかな