Understanding Unicode in JavaScript
Two strings that look identical on screen can be different byte-for-byte. Most Unicode confusion in JavaScript comes from three sources: escape syntax, encoding, and normalization.
Four ways to write the same character
"A" // literal
"\u0041" // 4-digit UTF-16 unit escape
"\u{41}" // braced code point escape (ES6)
String.fromCodePoint(0x41) // "A"
The braced form is the only one that takes code points above
U+FFFF directly: "\u{1F600}" is the emoji, while
"\uD83D\uDE00" is its surrogate-pair spelling.
Length is not what you think
.length counts UTF-16 units. Emoji, accented characters from
some scripts, and CJK ideographs outside the BMP count as 2 (or more, for
ZWJ sequences):
"a".length; // 1
"\u{1F600}".length; // 2
"π¨βπ©βπ§".length; // 8 (family emoji: 4 people + 3 ZWJ)
To count user-perceived characters, use the iterator or
Intl.Segmenter:
[..."π¨βπ©βπ§"].length; // 4 code points
new Intl.Segmenter().segment("π¨βπ©βπ§").length !== undefined
// iterate grapheme segments for the true count
Normalization: why equality fails
The same visual text can be encoded as a single code point or as a base
letter plus combining marks. "Γ©" exists both ways:
const a = "\u00E9"; // precomposed Γ©
const b = "e\u0301"; // e + combining acute
a === b; // false!
a.length; // 1
b.length; // 2
Normalize before comparing or indexing:
a.normalize("NFC") === b.normalize("NFC"); // true
NFC composes where possible (recommended for storage),
NFD decomposes (useful for stripping accents), and
NFKC/NFKD additionally unify compatibility forms
like β β 1.
Accent-insensitive search, the short way
function fold(s) {
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
fold("Crème brûlée"); // "creme brulee"
Checklist
- Normalize user input once, at the boundary, with
NFC. - Never use
.lengthfor visible-width logic. - Prefer
\u{...}escapes for non-BMP characters.