Unicode in JavaScript strings
Count and split text correctly, with code points, normalization, well-formed strings, grapheme segmentation and UTF-8 bytes.
What you will learn
- Tell UTF-16 code units, code points, graphemes and bytes apart
- Iterate and build strings by code point
- Compare and clean accented text with normalize
- Detect and repair broken surrogates with isWellFormed and toWellFormed
- Count and cut user-visible characters with Intl.Segmenter
- Percent-encode URL values and read the bitwise operators
On this page
Strings showed that "😀".length is 2 and that normalize() makes two spellings of é equal. This lesson explains why, and gives you the tools to get “first character”, “length” and “cut to N” right. The general ideas of encodings and text are in the fundamentals lesson on text and strings. Here the focus is on how JavaScript represents text.
Four ways to count text
The same piece of text has different sizes depending on what you count.
| Unit | What it is | JavaScript |
|---|---|---|
| Code unit | a 16-bit number, the storage unit of a string | length, charCodeAt, indexing |
| Code point | one Unicode character number (up to 0x10FFFF) |
codePointAt, for...of, spread |
| Grapheme | what a reader sees as one character | Intl.Segmenter |
| Byte | one 8-bit value in a file or network message | TextEncoder |
A JavaScript string is a sequence of UTF-16 code units. Characters above U+FFFF, including most emoji, take two code units, called a surrogate pair.
Code units and code points
The escape \u{...} writes a code point by number, with any number of digits.
const smile = "\u{1F600}";console.log(smile.length, smile === "😀");2 trueconsole.log(smile.charCodeAt(0).toString(16), smile.charCodeAt(1).toString(16));console.log(smile.codePointAt(0).toString(16));console.log(smile.codePointAt(1).toString(16));console.log(String.fromCodePoint(0x1f600) === smile);d83d de001f600de00true- The emoji is stored as two code units,
d83dthende00, in hex. codePointAt(0)reads the whole pair and gives1f600.codePointAt(1)points into the middle of the pair and returns only the second half,de00. Use it at positions you know are starts.String.fromCodePointis the reverse ofcodePointAt. The olderString.fromCharCodeonly handles one code unit.
Indexing, slice and length all work on code units. for...of and spread work on code points.
const word = "a😀b";console.log(word.length, [...word].length);console.log(word.slice(0, 2) === "a😀", word.slice(0, 2).length);4 3false 2for (const ch of word) { console.log(ch, ch.codePointAt(0));}a 97😀 128512b 98slice(0, 2) kept a and only half of the emoji, which is a broken string with length 2. Cutting by code unit can split a pair. Loop with for...of or spread first when you take parts of text that may contain emoji.
Normalization
Many characters have more than one spelling. é can be one code point (U+00E9) or e followed by a combining acute accent (U+0301). They look the same and compare as different.
const composed = "café";const decomposed = "café";console.log(composed === decomposed, composed.length, decomposed.length);false 4 5console.log(composed.normalize("NFD") === decomposed);console.log(decomposed.normalize("NFC") === composed);truetruenormalize takes a form name:
- NFC composes where it can (the default). Use it for storing and comparing.
- NFD decomposes into base letters and combining marks.
- NFKC and NFKD also replace “compatibility” characters, such as ligatures, with plain equivalents.
Decomposing is also the standard trick for stripping accents. After NFD, the marks are separate code points that the Unicode property \p{M} (mark) finds:
const stripAccents = (s) => s.normalize("NFD").replace(/\p{M}/gu, "");console.log(stripAccents("Crème brûlée, señor"));Creme brulee, senorThe compatibility forms are for matching and search, not for storage, because they lose information:
console.log("find".normalize("NFKC"), "①".normalize("NFKC"));console.log("find".normalize("NFC") === "find");find 1falseThe ligature fi becomes the two letters fi and the circled digit becomes 1 under NFKC, but NFC leaves the ligature alone.
Well-formed strings
JavaScript does not stop you from creating a string with a lone surrogate, one half of a pair with no partner. It is not valid Unicode text and cannot be encoded as UTF-8. It usually comes from slicing in the middle of an emoji. isWellFormed() checks for it and toWellFormed() replaces each lone surrogate with the replacement character U+FFFD.
const good = "ok \u{1F600}";const bad = "ok \uD83D";console.log(good.isWellFormed(), bad.isWellFormed());console.log(bad.toWellFormed() === "ok �");true falsetrueSome APIs refuse broken strings, others quietly change them.
try { encodeURIComponent(bad);} catch (error) { console.log(error.name);}console.log(encodeURIComponent(bad.toWellFormed()));console.log(JSON.stringify(bad));URIErrorok%20%EF%BF%BD"ok \ud83d"encodeURIComponentthrows aURIErroron a lone surrogate.- After
toWellFormed, it encodes the replacement character as%EF%BF%BD. JSON.stringifywrites the lone surrogate as the escape\ud83d, so the JSON stays valid.
Call toWellFormed() before sending text to something that insists on UTF-8, if the text could have been cut by code unit.
Graphemes with Intl.Segmenter
Even code points are not “characters” in the reader’s sense. A family emoji is three people joined by invisible zero-width joiners. A flag is two regional-indicator code points. é as e plus a combining mark is two code points. A reader sees one symbol in each case.
Intl.Segmenter splits text by the rules of a locale. With granularity: "grapheme" it splits into grapheme clusters, the user-visible characters.
const family = "👨👩👧";const flag = "🇯🇵";const accented = "é";
for (const s of [family, flag, accented]) { console.log(s.length, [...s].length);}8 54 22 2Each line shows the length and the code point count. The family emoji is 8 units and 5 code points, but one symbol.
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });const graphemes = (s) => Array.from(seg.segment(s), (part) => part.segment);
for (const s of [family, flag, accented]) { console.log(graphemes(s).length);}console.log(graphemes("a👨👩👧é"));111[ 'a', '👨👩👧', 'é' ]segmentreturns an iterable of parts, and each part has asegmentstring (and anindex).Array.fromwith a mapping function turns the parts into strings.- All three samples count as one grapheme, and the mixed string splits into three.
The segmenter has two more granularities. "word" marks the pieces of text that are words, via isWordLike:
const words = new Intl.Segmenter("en", { granularity: "word" });const text = "Well, it's 3.5 days!";
const found = [];for (const part of words.segment(text)) { if (part.isWordLike) { found.push(part.segment); }}console.log(found);[ 'Well', "it's", '3.5', 'days' ]Punctuation and spaces are segments too, but isWordLike is false for them. The segmenter knows that it's and 3.5 are each one word, which a plain split on spaces and punctuation would break. The third granularity is "sentence".
Creating a segmenter has a cost, so make one and reuse it.
Bytes and encodings
Files and networks carry bytes, and JavaScript text is turned into them with an encoding. UTF-8 is the common choice. The built-in TextEncoder always produces UTF-8, and TextDecoder reads it.
const encoder = new TextEncoder();for (const s of ["a", "é", "€", "😀"]) { console.log(s, s.length, encoder.encode(s).length);}a 1 1é 1 2€ 1 3😀 2 4const bytes = encoder.encode("é");console.log([...bytes]);console.log(new TextDecoder().decode(bytes));console.log(new TextDecoder().decode(Uint8Array.of(0xff, 0x41)));[ 195, 169 ]é�A- A string of one character takes between one and four bytes. The same emoji is 2 code units but 4 bytes.
- Decoding bytes that are not valid UTF-8 does not throw by default. Each bad byte becomes the replacement character
�.
So the length you need depends on the question. A database column with a byte limit needs TextEncoder. A tweet-like counter needs graphemes. An array index needs code units.
Percent-encoding for URLs
A URL may only carry a small set of ASCII characters. Anything else is written as its UTF-8 bytes, each as % and two hex digits. encodeURIComponent does that for one piece of a URL, such as a query value, and decodeURIComponent reverses it.
const query = "café & crème?";const encoded = encodeURIComponent(query);
console.log(encoded);console.log(decodeURIComponent(encoded) === query);console.log(encodeURIComponent("😀"));console.log(encodeURI("/search?q=café"));console.log(encodeURIComponent("/search?q=café"));caf%C3%A9%20%26%20cr%C3%A8me%3Ftrue%F0%9F%98%80/search?q=caf%C3%A9%2Fsearch%3Fq%3Dcaf%C3%A9The emoji becomes four bytes, as in the table above. The last two lines show the difference between the two encoders. encodeURI is for a whole URL, so it keeps the characters that give a URL its structure (/, ?, &, =, #). encodeURIComponent is for a single value, so it escapes them too. Use it for any text that a user typed, or the & in a name will split your query in two.
Both functions throw a URIError for input that has no valid encoding, and so does decodeURIComponent for a stray %. A lone surrogate is the same broken text that isWellFormed finds:
for (const attempt of [ () => decodeURIComponent("100%"), () => encodeURIComponent("\uD800"),]) { try { attempt(); } catch (error) { console.log(error.name, error.message); }}URIError URI malformedURIError URI malformedDecoding text from a request or a link is a place where this crash reaches a server, so wrap it in try/catch. When you build or read whole URLs, the URL and URLSearchParams classes in the files and paths lesson do the encoding for you.
Bit operators you will meet
Bytes bring the bitwise operators. You will not need them often, but you will read them in other people’s code: & (and), | (or), ^ (exclusive or), ~ (not), <<, >> and >>> (shifts). They convert their operands to 32-bit integers first. A common use is a set of on/off flags stored in one number:
const READ = 1;const WRITE = 2;const EXEC = 4;
let mode = READ | WRITE;console.log(mode, (mode & WRITE) !== 0, (mode & EXEC) !== 0);mode ^= READ;console.log(mode);
console.log(3.7 | 0, ~~-3.7, -1 >>> 0, 1 << 3, -16 >> 2, 2 ** 10);3 true false23 -3 4294967295 8 -4 1024| sets a flag, & tests it, and ^ toggles it. The last line shows idioms from older code. x | 0 and ~~x cut off the fraction, as Math.trunc does for numbers in the 32-bit range, and x >>> 0 turns a number into an unsigned 32-bit one. 1 << 3 is 8, and -16 >> 2 is -4. The ** operator in the same line is not a bit operator: it raises to a power. Prefer Math.trunc and ** when you write new code.
With bytes, & can look at the top bits. In UTF-8 a continuation byte starts with the bits 10, so counting the bytes that do not is a way to count characters without decoding:
const bytes = new TextEncoder().encode("a€😀");
// A continuation byte looks like 10xxxxxx. Every other byte starts a character.const starts = bytes.filter((byte) => (byte & 0b11000000) !== 0b10000000);console.log(bytes.length, starts.length);8 3The text is 8 bytes and 3 code points.
Common pitfalls
- Using
lengthas the number of characters. It counts code units. Count graphemes for user-visible length. - Taking
text[0]orslice(0, n)of text with emoji. It can cut a pair in half. UseIntl.Segmenterto take the first user-visible character. - Reversing with
split("").reverse().join(""). It scrambles surrogate pairs and combining marks. Reverse graphemes instead. - Comparing accented text without
normalize(). Two equal-looking strings can differ. - Using NFKC for storage. It loses distinctions. Use NFC for storage and comparisons, and the K forms for search.
- Using
\wor[a-z]for names. They only match ASCII letters. Use\p{L}with theuorvflag. - Mixing up characters and bytes.
lengthis not a size in bytes.
Exercises
Write firstChar(text) and charCount(text) that work with user-visible characters. For each of 😀 party, 👩💻 dev, 🇯🇵 Tokyo and école (spelled with a combining accent), print the first character, the character count and the plain length, separated by spaces. The expected output is 😀 7 8, 👩💻 5 9, 🇯🇵 7 10 and é 5 6.
Show solution
Taking the first item of the segmenter’s iterable gives the first grapheme. ?.segment ?? "" handles empty text, where there is no first part.
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
function firstChar(text) { const [first] = segmenter.segment(text); return first?.segment ?? "";}
function charCount(text) { return [...segmenter.segment(text)].length;}
for (const s of ["😀 party", "👩💻 dev", "🇯🇵 Tokyo", "école"]) { console.log(firstChar(s), charCount(s), s.length);}😀 7 8👩💻 5 9🇯🇵 7 10é 5 6Write slugify(title) that turns a title into a URL slug: accents removed, lowercase, every run of other characters replaced by one -, and no leading or trailing -. slugify("Crème Brûlée à la Carte!") gives creme-brulee-a-la-carte and slugify(" Año nuevo -- 2027 ") gives ano-nuevo-2027.
Show solution
NFD plus \p{M} strips the accents. Lowercase text then keeps only a-z and digits, and the last replace trims dashes at either end.
function slugify(title) { return title .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "");}
console.log(slugify("Crème Brûlée à la Carte!"));console.log(slugify(" Año nuevo -- 2027 "));creme-brulee-a-la-carteano-nuevo-2027Write truncate(text, max) that returns the text unchanged when it has at most max graphemes, and otherwise the first max - 1 graphemes followed by …. Try Hello, world with 8, four family emoji (👨👩👧) with 3, and short with 8. The expected output is Hello, …, two family emoji followed by …, and short.
Show solution
The text is split into graphemes first, so the join can never cut a family emoji in half.
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
function truncate(text, max) { const parts = Array.from(segmenter.segment(text), (p) => p.segment); if (parts.length <= max) { return text; } return parts.slice(0, max - 1).join("") + "…";}
console.log(truncate("Hello, world", 8));console.log(truncate("👨👩👧👨👩👧👨👩👧👨👩👧", 3));console.log(truncate("short", 8));Hello, …👨👩👧👨👩👧…shortA database column holds at most 10 bytes of UTF-8. Write cutBytes(text, maxBytes) that returns the longest start of the text that fits, never splitting a character. Print the result for Héllo wörld 😀 with limit 10, followed by its byte size. Then print cutBytes("ab😀", 5) and cutBytes("ab😀", 6). The expected output is Héllo wö 10 and then ab ab😀.
Show solution
Looping with for...of visits whole code points. Each one is measured with TextEncoder, and the loop stops before the first one that would not fit.
const encoder = new TextEncoder();
function cutBytes(text, maxBytes) { let used = 0; let result = ""; for (const ch of text) { const size = encoder.encode(ch).length; if (used + size > maxBytes) { break; } used += size; result += ch; } return result;}
const cut = cutBytes("Héllo wörld 😀", 10);console.log(cut, encoder.encode(cut).length);console.log(cutBytes("ab😀", 5), cutBytes("ab😀", 6));Héllo wö 10ab ab😀Summary
- A string is a sequence of UTF-16 code units.
lengthand indexing use them;for...of, spread andcodePointAtuse code points. - A visible character can be several code points.
Intl.Segmenterwithgranularity: "grapheme"counts and splits them correctly. normalize("NFC")makes spellings comparable. NFD plus\p{M}strips accents. The K forms are for search.isWellFormedandtoWellFormedfind and repair lone surrogates, which some APIs reject.TextEncodergives UTF-8 bytes. Characters, code units and bytes are three different counts.encodeURIComponentpercent-encodes the UTF-8 bytes of one URL value, and throws aURIErroron broken text. Bit operators work on 32-bit integers.