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 -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

  1. Step 1.

    Print the four measurements for five samples that a test data set should contain.

    node lengths.mjs
    
    sample        .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           6

    Rows 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.

  2. 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 4

    A limit of 10 written as s.length <= 10 in Node rejects this name. The same limit written as length(v) <= 10 in SQLite accepts it. Python agrees with SQLite, because both count code points.

  3. 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.

  4. 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.

  5. Step 5.

    Cut by grapheme and check the byte size that results.

    node safe-cut.mjs
    
    graphemes  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 false

    No 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

Sign: The same limit passes in one service and fails in another with no code change between them.Cause: Node counts UTF-16 code units, Python and SQLite count code points. One family emoji is 11 units and 7 points, so a limit of 10 rejects it in Node and accepts it in the other two. Write the limit as a grapheme count or a byte count and apply the same one everywhere.
Sign: A grapheme-safe truncation still produces the wrong flag.Cause: Slicing between two flag emoji leaves an odd number of regional indicators, and the remaining ones pair up with their new neighbours. 🇺🇦🇬🇧 becomes 🇦🇬🇧 after dropping one pair of code units, and the grapheme count stays at 2. Only a code point comparison shows the change.
Sign: A saved display name ends in a black diamond or a box.Cause: A cut inside a surrogate pair leaves an unpaired code unit, which is a valid JavaScript string but not valid Unicode text. Every UTF-8 encoder writes EF BF BD for it, so the store holds a replacement character and the original code point cannot be recovered.
Sign: A field limited to 20 characters is rejected by the database at 20 characters.Cause: Two graphemes of emoji are 25 bytes. Engines that size a column in bytes count those, not characters. Test the byte length of the worst case as well as its display length.

What to check next

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.

intermediate6 minpublished updated Maks Verny