How to check string length with emoji
Measure the same value three ways. s.length counts UTF-16 code units, spreading the string counts code points, and Intl.Segmenter at grapheme granularity counts what a reader calls a character. One family emoji answers 11, 7 and 1, and its UTF-8 size is 25 bytes.
Why check this
A character limit is written once and enforced in four places: the input element, the client validator, the API schema and the column. Each of those counts something different, so a name that the form accepts can be rejected by the API, and a name the API accepts can be cut in half by the column.
Run this when a field gains a length limit, when storage moves between engines, and on any field that reaches a user profile or a display name. The failure it catches is a saved value that comes back with a replacement character on the end, because the store cut a surrogate pair and nothing on the way in complained.
Prerequisites
- Node 22 with full ICU.
Intl.Segmenteris the only grapheme counter in the platform, and it needs the ICU data to be present. - Python 3.13 and sqlite3 3.50, to show what a second and third runtime answer for the same stored value.
- One sample file holding a single family emoji, which is four people joined by three zero width joiners.
node -e "require('fs').writeFileSync('name.txt','\u{1F468}\u{1F469}\u{1F467}\u{1F466}','utf8')"
lengths.mjs prints the four measurements side by side.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const samples = {
family: '\u{1F468}\u{1F469}\u{1F467}\u{1F466}',
'flag UA': '\u{1F1FA}\u{1F1E6}',
'e + U+0301': 'é',
'U+00E9': 'é',
'Tamil ni': 'நி',
};
console.log('sample .length spread graphemes utf8 bytes');
for (const [name, s] of Object.entries(samples)) {
console.log(
name.padEnd(13),
String(s.length).padStart(6),
String([...s].length).padStart(7),
String([...seg.segment(s)].length).padStart(10),
String(Buffer.byteLength(s, 'utf8')).padStart(11)
);
}
safe-cut.mjs cuts by grapheme instead of by code unit.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
function graphemeSlice(s, n) {
return [...seg.segment(s)].slice(0, n).map((g) => g.segment).join('');
}
const cases = ['\u{1F468}\u{1F469}\u{1F467}\u{1F466}', '\u{1F1FA}\u{1F1E6}\u{1F1EC}\u{1F1E7}', 'Ana é'];
for (const s of cases) {
const cut = graphemeSlice(s, 2);
console.log(
'graphemes', String([...seg.segment(s)].length).padStart(2),
'-> cut to 2:', 'code units', String(cut.length).padStart(2),
'bytes', String(Buffer.byteLength(cut)).padStart(2),
'lone surrogate', /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(cut)
);
}
Steps
- Step 1.
Print the four measurements for five samples that a test data set should contain.
node lengths.mjssample .length spread graphemes utf8 bytes family 11 7 1 25 flag UA 4 2 1 8 e + U+0301 2 2 1 3 U+00E9 1 1 1 2 Tamil ni 2 2 1 6Rows three and four are the same word on screen. One is a letter plus a combining acute, the other is a single precomposed code point, and only the byte count separates them.
- Step 2.
Measure the same stored value in three runtimes, because the limit is enforced in all three.
node -e "const s=require('fs').readFileSync('name.txt','utf8');console.log('node ','units',s.length,'points',[...s].length,'graphemes',[...new Intl.Segmenter('en',{granularity:'grapheme'}).segment(s)].length)" python -c "print('python ','len',len(open('name.txt',encoding='utf-8').read()))" sqlite3 :memory: "create table t(v text); insert into t values (cast(readfile('name.txt') as text)); select 'sqlite chars ' || length(v) || ' bytes ' || length(cast(v as blob)) || ' substr(v,1,1) bytes ' || length(cast(substr(v,1,1) as blob)) from t;"node units 11 points 7 graphemes 1 python len 7 sqlite chars 7 bytes 25 substr(v,1,1) bytes 4A limit of 10 written as
s.length <= 10in Node rejects this name. The same limit written aslength(v) <= 10in SQLite accepts it. Python agrees with SQLite, because both count code points. - Step 3.
Cut the value the way a code unit limit cuts it, and look at the last unit.
node -e "const s='\u{1F468}\u{1F469}\u{1F467}\u{1F466}';for(const n of [4,5,10]){const c=s.slice(0,n),l=c.charCodeAt(c.length-1);console.log('slice(0,'+String(n).padStart(2)+')','ends U+'+l.toString(16).toUpperCase(),'lone surrogate',String(l>=0xd800&&l<=0xdbff).padEnd(5),'utf-8 bytes',Buffer.byteLength(c),JSON.stringify(c.slice(-1)))}"slice(0, 4) ends U+D83D lone surrogate true utf-8 bytes 10 "\ud83d" slice(0, 5) ends U+DC69 lone surrogate false utf-8 bytes 11 "\udc69" slice(0,10) ends U+D83D lone surrogate true utf-8 bytes 24 "\ud83d"Two of the three cuts end on a high surrogate with no partner. Encoding that to UTF-8 yields the three bytes
EF BF BD, the replacement character, and the original code point is gone. - Step 4.
Cut a pair of flags and count the graphemes again.
node -e "const seg=new Intl.Segmenter('en',{granularity:'grapheme'});const s='\u{1F1FA}\u{1F1E6}\u{1F1EC}\u{1F1E7}',cut=s.slice(2);const names=x=>[...seg.segment(x)].map(g=>[...g.segment].map(c=>'U+'+c.codePointAt(0).toString(16).toUpperCase()).join('+')).join(' ');console.log('original graphemes',[...seg.segment(s)].length,'|',names(s));console.log('after slice(2) ',[...seg.segment(cut)].length,'|',names(cut));console.log('rendered ',s,'->',cut)"original graphemes 2 | U+1F1FA+U+1F1E6 U+1F1EC+U+1F1E7 after slice(2) 2 | U+1F1E6+U+1F1EC U+1F1E7 rendered 🇺🇦🇬🇧 -> 🇦🇬🇧Dropping one code unit pair re-paired the regional indicators. Ukraine and the United Kingdom became Antigua and Barbuda plus a stray letter, and the grapheme count is 2 before and after, so counting graphemes does not detect this.
- Step 5.
Cut by grapheme and check the byte size that results.
node safe-cut.mjsgraphemes 1 -> cut to 2: code units 11 bytes 25 lone surrogate false graphemes 2 -> cut to 2: code units 8 bytes 16 lone surrogate false graphemes 5 -> cut to 2: code units 2 bytes 2 lone surrogate falseNo lone surrogates and no re-paired flags. Two graphemes still cost 25 bytes on the first row, so a grapheme limit does not protect a byte-sized column.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| .length far above the grapheme count | The value holds astral characters | Count graphemes for a display limit, not code units. |
| Two rows identical on screen with different byte counts | One is composed, one is decomposed | Normalise before you compare or store. |
| lone surrogate true after a cut | The cut landed inside a surrogate pair | Cut with a segmenter, or the encoder writes EF BF BD. |
| Grapheme count unchanged after a bad cut | Regional indicators re-paired | Compare the code points, not the count. |
| Byte count above the column size | Storage will truncate or reject | Check Buffer.byteLength against the column, separately from the display limit. |
Common mistakes
What to check next
- How to check if text is truncated: finds where a value was cut before it reached the screen.
- How to test text expansion in translations: the same limits under a language that needs more room.
- How to test unicode input: drives these values through a real form and reads them back.
- How to check for garbled characters from the wrong encoding: tells a truncation artefact apart from an encoding fault.
- Localization testing checklist: where this check sits in a release pass.
FAQ
Which length should a character limit use?
Graphemes for anything a person reads and types, bytes for anything a column sizes. Code units are an implementation detail of UTF-16 and make a poor contract. State which one the limit means in the API documentation.
Why does length return 11 for one emoji?
A family emoji is four people joined by three zero width joiners. Each person is one astral code point, stored as two UTF-16 code units, so four pairs plus three joiners give 11.
Does Intl.Segmenter need a locale?
It takes one, and grapheme segmentation rarely depends on it. Pass a locale anyway, because word and sentence granularity do depend on it and the same segmenter is reused.
Is a grapheme count enough to truncate safely?
Almost. It never leaves a lone surrogate, and it does not protect a byte-sized column or a run of flag emoji. Cut by grapheme, then check the byte size of the result.
Verified
Verified by Maks Vernynode 22.23.2python 3.13.1sqlite3 3.50.6
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate6 minpublished updated Maks Verny