JAVASCRIPT / STRINGS AND TEMPLATE LITERALS
Escapes, unicode, and emoji pitfalls
Write \n, \xHH, \uHHHH and \u{...} escapes correctly, and count, slice and compare strings holding emoji or accents without breaking them.
What you will learn
- \xHH takes two hex digits, \uHHHH exactly four, \u{...} up to six for any code point
- Read .length as UTF-16 code units, not characters: '๐'.length is 2
- Walk code points with for...of or [...str] so surrogate pairs never get split
- Use normalize('NFC') before comparing accents, Intl.Segmenter to count glyphs
Understanding Escapes, unicode, and emoji pitfalls
A backslash inside a string literal is an instruction to the parser, not a character in the finished value: '\n' has length 1, and '\\' is a single backslash. Three numeric escapes name a character by its code: \xHH takes exactly two hex digits and reaches U+00FF, \uHHHH takes exactly four, and \u{...} takes one to six digits and reaches any code point up to U+10FFFF. That exactness is where people get burned, because '\u1F600' is not ๐ but U+1F60 (แฝ ) followed by the digit 0 once \u stops after four digits. Escapes the parser does not recognise, like \q, quietly drop the backslash and evaluate to q, so a misspelled escape never raises an error.
Underneath, a JavaScript string is a sequence of 16-bit UTF-16 code units. Code points up to U+FFFF occupy one unit; anything above that is stored as a surrogate pair, one unit from D800-DBFF followed by one from DC00-DFFF. That is why length, str[i], charAt, charCodeAt, slice and substring all measure and cut in code units, while codePointAt, for...of and spread operate on whole code points. Nothing checks that pairs stay intact, so cutting between them yields a lone surrogate: still a legal JavaScript string, but one that renders as a replacement box and makes encodeURIComponent throw URIError.
What a reader calls one character is a grapheme cluster, and it can span many code points: ๐ฉโ๐ป is a woman, a zero-width joiner and a computer; ๐ฏ๐ต is two regional indicator letters; ๐๐ฝ is a thumb plus a skin-tone modifier. The same looseness applies to accents, since รฉ can be the single code point U+00E9 or the letter e followed by combining acute U+0301, and those two strings are not === even though they render identically. normalize('NFC') collapses the decomposed form into the composed one so comparisons and Set deduplication behave, and Intl.Segmenter with granularity 'grapheme' is the only built-in that counts clusters the way a person would.
The practical rule is to name the unit you actually mean before you measure or cut, and to reach for the code-point or grapheme tool as soon as text can come from a user.
console.log('line one\nline two');
console.log('backslash: \\ and quote: \'');
console.log(`escaped interpolation: \${1 + 1}`);
// \xHH, \uHHHH and \u{...} can all spell the same character
console.log('\xE9', '\u00E9', '\u{E9}');
// U+1F600 does not fit in a single UTF-16 code unit
const face = '\u{1F600}';
const pair = '\uD83D\uDE00';
console.log(face, face === pair);
console.log('length:', face.length, 'code points:', [...face].length);
console.log('first code unit:', face.charCodeAt(0).toString(16));
console.log('first code point:', face.codePointAt(0).toString(16));A JavaScript string is a sequence of UTF-16 code units, so code unit, code point and grapheme cluster are three different counts and only the last matches what a person sees.
Worked examples
Cutting between a surrogate pair
Shows how slice can leave half an emoji behind, and how spreading first avoids it.
const msg = 'hi ๐!';
const cut = msg.slice(0, 4);
console.log(msg.length, [...msg].length);
console.log(cut.length, cut.charCodeAt(3).toString(16));
console.log(cut === 'hi \uD83D');
console.log([...msg].slice(0, 4).join(''));Example explained
Line 1msg.length is 6 while the string holds only 5 code points, because ๐ occupies two UTF-16 code units.
Line 2slice(0, 4) keeps just the first unit of the pair, so cut ends with code unit d83d.
Line 3That truncated string is exactly equal to 'hi \uD83D': JavaScript stores the orphaned high surrogate without complaint.
Line 4Spreading converts the string to an array of code points first, so slicing there can never split ๐.
Two spellings of the same accent
Demonstrates canonical equivalence and why normalize is needed before comparing accented text.
const composed = '\u00E9'; // รฉ as one code point
const decomposed = 'e\u0301'; // e + combining acute accent
console.log(composed, decomposed);
console.log(composed === decomposed);
console.log(composed.length, decomposed.length);
console.log(composed === decomposed.normalize('NFC'));
console.log(composed.indexOf('e'), decomposed.indexOf('e'));Example explained
Line 1Both values print as the same glyph, but composed is U+00E9 and decomposed is U+0065 followed by U+0301.
Line 2=== compares code units one by one, so identical-looking strings compare unequal.
Line 3normalize('NFC') merges the base letter and the combining mark into the single code point, making the comparison true.
Line 4indexOf('e') returns -1 for the composed form and 0 for the decomposed one, since only the decomposed form really contains a plain e.
Three different lengths for one glyph
Compares code units, code points and grapheme clusters for joined and modified emoji.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
for (const s of ['๐ฉโ๐ป', '๐ฏ๐ต', '๐จโ๐ฉโ๐งโ๐ฆ']) {
console.log(s, s.length, [...s].length, [...seg.segment(s)].length);
}Example explained
Line 1s.length counts UTF-16 code units, so every emoji contributes 2 and every zero-width joiner contributes 1.
Line 2[...s] uses the string iterator, which yields code points, so the joiners and the two flag letters still count separately.
Line 3Intl.Segmenter with granularity 'grapheme' groups joiner sequences and regional indicator pairs, returning the count a person would give.
Line 4The family emoji is a single glyph built from 7 code points and 11 code units, which is why no plain length property agrees with what you see.
Important notes
Legacy octal escapes like '\101' still produce 'A' in sloppy-mode string literals but are a SyntaxError in strict mode and in template literals; write '\x41'. A bare \0 not followed by a digit is allowed everywhere and means NUL.
String.raw`C:\new` returns the six characters C, :, \, n, e, w, which is what you want for Windows paths and regex source, whereas a normal literal would turn \n into a newline.
Common mistakes
Writing '\u1F600' and expecting ๐: \u reads exactly four hex digits, so the value is 'แฝ 0' โ U+1F60 plus a stray zero. Write '\u{1F600}' instead.
Trimming a preview with str.slice(0, 100): if code unit 99 starts a surrogate pair, the result ends in a lone surrogate that renders as a replacement box and makes encodeURIComponent throw URIError: URI malformed.
Enforcing a character limit with value.length: a flag costs 4 units and a family emoji costs 11, so users get rejected long before they reach the visible limit.
Try it yourself
Change, predict, then run
In a browser console, build 'cafรฉ' twice โ once ending in '\u00E9' and once in 'e\u0301' โ then log both lengths, whether === holds, and whether normalize('NFC') makes them equal. Then log .length, [...s].length and the Intl.Segmenter grapheme count for '๐๐ฝ'.
Open the JavaScript workspaceCheck your understanding
A form rejects input longer than 20 using value.length. A user types 20 visible characters, three of which are flag emoji, and is rejected. Why?
- Each flag is two regional indicator letters, each stored as a surrogate pair, so 17 plain characters plus 3 flags read as 29 code units
- length reports UTF-8 bytes, and each emoji takes 4 bytes
- length also counts the zero-width joiners that hold each flag together
- The engine normalizes emoji into a longer decomposed form before length is read
Show answer
length counts UTF-16 code units. A flag is two regional indicator code points above U+FFFF, so each one needs two surrogate pairs and adds 4: 17 + 12 = 29. The UTF-8 byte option is tempting because emoji really are 4 bytes each in UTF-8, but length never reports bytes, and a flag would be 8 UTF-8 bytes anyway; flags contain no zero-width joiner at all.