Topic 4.2
Unicode, Emoji and String Length
In one line
Every character has a Unicode number (a code point), but JavaScript stores strings as 16-bit pieces called UTF-16 code units, so length counts pieces, not characters: one emoji can have a length of 2, 4 or more. Use for...of, spread or Intl.Segmenter to work with real characters, and normalize() before comparing text.
Think of it like this
Lego. Most letters are a small brick: one brick each. Some characters, like emoji, are too big for one brick, so they are built from two bricks clipped together. A family emoji is several figures joined with invisible clips. If you count bricks (length), a family looks like 8 things. If you count figures (code points), you get 5. A person looking at the screen sees 1 picture. Text tools must decide which of the three they're counting.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Unicode
- The worldwide list that gives every character and emoji its own number.
- Code point
- The Unicode number of one character, written like U+0041 for A.
- Encoding
- A rule for storing code points as bytes or pieces, such as UTF-8 or UTF-16.
- Code unit
- One 16-bit piece of a JavaScript string.
lengthcounts these. - Surrogate pair
- Two code units that together store one big code point, such as most emoji.
- Lone surrogate
- Half of a surrogate pair on its own. It isn't a valid character and shows as �.
- Grapheme cluster
- What a reader sees as one character, even if it's made of several code points.
- Zero-width joiner (ZWJ)
- An invisible character (U+200D) that glues emoji together into one picture.
- Normalisation
- Turning text into one standard form, so things that look the same are stored the same.
Step by step
01Count a plain word
For everyday letters there's no surprise: one letter, one code unit. "Hello".length is 5, and "Hello"[1] is "e".
console.log("Hello".length); // 5
console.log("Hello".codePointAt(0)); // 72, the code point of H02Count an emoji
The grinning face is code point U+1F600 (128512 in decimal). That's bigger than 65535, so UTF-16 stores it as two code units: 0xD83D and 0xDE00. length counts both.
console.log("😀".length); // 2
console.log("😀".charCodeAt(0)); // 55357 (0xD83D, half of the pair)
console.log("😀".codePointAt(0)); // 128512 (the real code point)
console.log([..."😀"].length); // 103Loop by code points
A classic for loop with i++ walks code units and splits the emoji in half. for...of walks code points, so each emoji arrives whole (unless it's a cluster like a family, which arrives as its parts).
for (const ch of "a😀b") console.log(ch); // a, 😀, b
// for (let i = 0; i < s.length; i++) would give: a, �, �, b04Count what people see
Intl.Segmenter splits text into graphemes using Unicode's rules. segment() returns an iterable, so spreading it gives an array of segments. Its length is the number of visible characters.
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
const count = (s) => [...seg.segment(s)].length;
console.log(count("👨👩👧 hi")); // 405Compare text safely
Text from a Mac file name, a copy-paste or a different keyboard may arrive in NFD form. Normalise both sides before you compare them.
const a = "caf\u00e9"; // é as one code point
const b = "cafe\u0301"; // e + combining accent
console.log(a === b); // false
console.log(a.normalize() === b.normalize()); // trueTry it yourself
- 1
Add your own samples
In the first example, add
"\u{1F44B}\u{1F3FF}"(a waving hand with a skin tone) and the word"naïve". Predict the three numbers for each before you run it. - 2
Cut without breaking
In the second example, add
console.log(s.slice(0, 2));and thenconsole.log([...s].slice(0, 2).join(""));. Predict which one shows a broken character. - 3
Measure bytes
Still in the second example, measure the UTF-8 bytes of
"naïve"and of"नमस्ते"withTextEncoder. Which letters need more than one byte?
Code & diagrams
The last sample is the flag of India: two "regional indicator" letters, I and N, that the screen draws as one flag.
Preview
Console
Nothing logged yet.Expected console output
cat 3 3 3
😀 2 1 1
👍🏽 4 2 1
👨👩👧 8 5 1
🇮🇳 4 2 1`isWellFormed` (ES2024) returns `false` when a string contains a lone surrogate, half an emoji. The UTF-8 count is 1 + 4 + 1 bytes.
Preview
Console
Nothing logged yet.Expected console output
4
55357 56832
128512 1f600
true
4 3
false true
false b😀a
6 bytes in UTF-8Both words print as "café" but are stored differently. `\uFB01` is the single ligature character "fi", which NFKC turns into the two letters "fi".
Preview
Console
Nothing logged yet.Expected console output
café café false
5 4
true
5
file
STRASSE
I İBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Limit a name by length
Cut a display name to 5 "characters" with name.slice(0, 5) where name = "Riya👩💻".
Break #2
Compare a pasted word
Check if (input === "café") where the user pasted café from a document saved in NFD form.
Myth vs fact
Myth
length is the number of characters.
Fact
It's the number of UTF-16 code units. It matches what you see only for text without emoji, combining marks and some rare scripts.
Myth
JavaScript strings are UTF-8.
Fact
Source files and network text are usually UTF-8, but inside the engine a string is a sequence of UTF-16 code units. Engines may store pure-Latin-1 strings with one byte per character internally, but the language still behaves as UTF-16.
Myth
for...of gives you visible characters.
Fact
It gives code points. A skin-tone emoji or a family still comes out in parts. Only Intl.Segmenter gives grapheme clusters.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
UTF-16 came from the 1990s idea that 65536 characters would be enough (UCS-2). When Unicode grew past that, surrogate pairs were added. JavaScript, Java and Windows all kept 16-bit strings for compatibility, which is why they share these problems.
- ▸
V8 stores strings that only contain characters up to
U+00FFas one-byte strings internally and switches to two bytes per unit when a wider character appears. It's invisible to your code but affects memory use. - ▸
Regular expressions match code units unless you add the
u(or the newerv) flag:/^.$/.test("😀")isfalse,/^.$/u.test("😀")istrue. Withuyou can also use property escapes like\p{L}(any letter) and\p{Emoji}. Topic 4.3 covers flags. - ▸
Grapheme rules change as Unicode adds emoji, and
Intl.Segmenteruses the browser's built-in ICU data, so very new emoji may segment differently in an old browser.Intl.Segmenterhas worked in all major browsers since 2024 (Firefox was last).
Remember this
- 1
Unicode is the world's list of characters: every letter, digit, symbol and emoji in every language gets a number called a code point, written like
U+0041(that'sA) orU+1F600(😀). There is room for over a million code points, fromU+0000toU+10FFFF. Unicode only says which number means which character; an encoding says how to store those numbers as bytes. The web sends text as UTF-8 (1 to 4 bytes per code point), but inside JavaScript strings use UTF-16. - 2
In UTF-16, text is stored in 16-bit pieces called code units (each holds a number from 0 to 65535). Code points up to
U+FFFF(most letters in most languages, called the Basic Multilingual Plane) fit in one code unit. Bigger code points, including almost all emoji, need two code units called a surrogate pair: a "high" one (0xD800–0xDBFF) followed by a "low" one (0xDC00–0xDFFF). JavaScript'slength,[i],charAt,charCodeAt,sliceandsplit("")all work in code units. So"😀".lengthis2and"😀"[0]is half an emoji (a lone surrogate, which shows as �). - 3
To work with code points, use the tools added in ES2015:
for...ofloops over a string one code point at a time, and so do spread[...str]andArray.from(str).codePointAt(i)reads the full number at a position,String.fromCodePoint(n)builds a character from it, and the escape\u{1F600}writes any code point in source code (the older\u0041form takes exactly four hex digits, so it can only write one code unit).[...str].lengthcounts code points. - 4
Even code points aren't what a person calls "a character". A grapheme cluster is what a reader sees as one character:
👍🏽is a thumbs-up plus a skin-tone modifier (2 code points, 4 code units), and👨👩👧is three people joined by zero-width joiners (ZWJ,U+200D): 5 code points, 8 code units, 1 visible picture. Flags are two regional-indicator letters. To count or cut by what the user sees, use **Intl.Segmenter** withgranularity: "grapheme"; it follows Unicode's segmentation rules. It's the right tool for a "characters left" counter or for cutting a long name without breaking an emoji. - 5
The same visible text can be stored in more than one way.
écan be one code point (U+00E9, "precomposed") or two:efollowed by a combining acute accent (U+0301). They look identical but===says they're different, and their lengths differ.str.normalize()(ES2015) converts text to a standard form: NFC (the default) composes characters where possible, NFD splits them apart. Normalise to NFC before comparing, searching or storing user text. The NFKC form also folds look-alike "compatibility" characters, such as the ligaturefiintofi. - 6
Other places the gap between code units and real characters shows up: reversing a string with
split("").reverse().join("")breaks emoji (spread first instead);toUpperCasecan change the length ("ß".toUpperCase()is"SS") and depends on language ("i".toLocaleUpperCase("tr")is"İ"in Turkish); and a database column or an API limit usually counts UTF-8 bytes, whichnew TextEncoder().encode(str).lengthmeasures (😀is 4 bytes). ES2024 addedisWellFormed()andtoWellFormed()to find and repair lone surrogates before you send text to an API that would reject it.
Explain it without notes
Why is "😀".length equal to 2?
What is the difference between a code unit, a code point and a grapheme cluster?
How would you count the characters a user sees in a text box?
Why can two strings that look identical fail ===, and how do you fix it?
Practice
Write reverseText(s) that reverses a string without breaking emoji like 😀 (code points are enough here).
Write charsLeft(text, max) that returns how many visible characters are left, counting graphemes, so charsLeft("hi👍🏽", 10) returns 7.
Trade-offs
- ↔
Intl.Segmenteris correct for what people see but slower thanlength; uselengthfor internal limits and protocol work, and graphemes for anything a person counts. - ↔
Normalising on input (store NFC everywhere) costs a little at write time but makes every later comparison and search simple; normalising at every comparison is easy to forget.
Done when you can
I can explain code units, code points and grapheme clusters with an emoji example.
I know
length,[i],sliceandsplit("")work in UTF-16 code units.I can loop over code points with
for...ofand spread a string safely.I can count visible characters with
Intl.Segmenter.I normalise text with
normalize()before comparing it.