JavaScript Strings Have Three Useful Notions of Character
How UTF-16 code units, Unicode code points, and grapheme clusters affect indexing and validation.
The word βcharacterβ is too vague for reliable string code. JavaScript strings are sequences of UTF-16 code units, Unicode assigns code points, and users perceive grapheme clusters. The three counts often agree for basic Latin text and diverge for emoji, combining marks, and many writing systems.
Indexing exposes code units
String.prototype.length, bracket indexing, charAt, and charCodeAt operate in UTF-16 code units.
const symbol = 'π';
console.log(symbol.length); // 2
console.log(symbol.charCodeAt(0)); // 0xD834
console.log(symbol.charCodeAt(1)); // 0xDD1E
The musical symbol is one Unicode code point encoded as a surrogate pair. Slicing between those units creates unpaired surrogates, which are valid contents for a JavaScript string even though they are not well-formed Unicode scalar values.
codePointAt(index) combines a high surrogate with a following low surrogate. It also works symmetrically when called at the low-surrogate index: symbol.codePointAt(1) walks backward to the high surrogate and returns the complete code point. This makes it safe to call at any index produced by an ordinary code-unit loop.
String iteration follows code points
The string iterator recognises surrogate pairs. for...of, spread, and Array.from therefore split a well-formed string by code point rather than code unit.
console.log([...symbol].length); // 1
for (const codePoint of 'AπB') {
console.log(codePoint);
}
Code-point iteration still does not match visible symbols. The family emoji π¨βπ©βπ§βπ¦ contains several emoji code points joined into one grapheme cluster. A letter followed by a combining accent may also render as a single unit.
Grapheme segmentation is locale-aware
Intl.Segmenter can segment text into grapheme clusters without maintaining a home-grown table of combining rules.
const segmenter = new Intl.Segmenter('en', {
granularity: 'grapheme',
});
const clusters = [...segmenter.segment('π¨βπ©βπ§βπ¦ cafΓ©')];
console.log(clusters.map(({ segment }) => segment));
The same API can segment words or sentences, where locale has a larger effect. User-facing truncation, cursor movement, and character limits should usually be stated in terms of graphemes rather than code units.
Normalisation addresses canonical equivalence
Unicode can represent some text in multiple canonically equivalent ways. Γ© may be one code point or e followed by a combining acute accent. They render similarly but compare unequal until normalised.
const composed = '\u00E9';
const decomposed = 'e\u0301';
console.log(composed === decomposed); // false
console.log(composed.normalize('NFC') === decomposed.normalize('NFC')); // true
NFC also expands compatibility characters such as the ο¬ ligature into their plain-letter forms. After NFC normalisation, visually equivalent typography can therefore be compared with === without additional security or locale considerations.
Normalisation is useful for consistent storage and comparison, but application policy still matters. Case folding, locale-sensitive sorting, confusable characters, and identifier security are separate concerns. Choose the unit and comparison rule that correspond to the user-visible operation instead of using length as a universal measure.