Strings: UTF-16, Ropes & Grapheme Clusters
🙂 ek character hai — phir bhi .length 2 kyun? Aaj pata chalega bhai.
Unicode is a mapping of numbers (code points) to characters — U+0041 = 'A', U+03A9 = 'Ω', U+1F642 = '🙂'. The full Unicode range goes from U+0000 to U+10FFFF — that's 1,114,112 possible code points.
UTF-16 encoding: BMP characters = 2 bytes (1 code unit). Supplementary characters = 4 bytes (2 code units, called a surrogate pair).
JavaScript chose UTF-16 in 1995 when Unicode was still only the BMP — the 4-byte extension came later. The result: JS strings are sequences of UTF-16 code UNITS (2 bytes each), not characters. String.length counts code UNITS, not characters or code points.
// ASCII / BMP characters — 1 code unit (2 bytes)
console.log('A'.length); // 1 — U+0041, one code unit
console.log('Ω'.length); // 1 — U+03A9, still BMP
console.log('中'.length); // 1 — U+4E2D, Chinese character, BMP
// Supplementary plane — emoji, needs 2 code units (4 bytes)
console.log('🙂'.length); // 2 — U+1F642, outside BMP!
console.log('👨👩👧👦'.length); // 11 — family emoji = multiple code points!
console.log('🏴'.length); // huge — flag emoji with tag characters!
// Code point vs code unit
const smile = '🙂';
console.log(smile.length); // 2 — two UTF-16 code units
console.log([...smile].length); // 1 — one actual character (using iterator)
console.log(smile.codePointAt(0)); // 128578 — the actual Unicode code point
console.log(smile.charCodeAt(0)); // 55357 — first surrogate (broken!)
console.log(smile.charCodeAt(1)); // 56898 — second surrogate (broken!)
Supplementary plane code points (U+10000 to U+10FFFF) don't fit in 2 bytes. The Unicode consortium's solution: encode one code point using TWO 2-byte code units — a surrogate pair.
codePoint = (high - 0xD800) × 0x400 + (low - 0xDC00) + 0x10000A lone surrogate is a high or low surrogate without its pair — a malformed string. JS allows lone surrogates; other languages/specs don't.
// Manually inspect a surrogate pair
const emoji = '🙂'; // U+1F642
// Get the two code units (surrogates)
const high = emoji.charCodeAt(0); // 55357 = 0xD83D (high surrogate)
const low = emoji.charCodeAt(1); // 56898 = 0xDE42 (low surrogate)
console.log(high.toString(16)); // d83d — high surrogate range D800-DBFF
console.log(low.toString(16)); // de42 — low surrogate range DC00-DFFF
// Reconstruct code point from surrogate pair:
const codePoint = (high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000;
console.log(codePoint.toString(16)); // 1f642 — correct! U+1F642
// ES6+ correct way to get code points
console.log(emoji.codePointAt(0)); // 128578 (0x1F642)
console.log(String.fromCodePoint(128578));// '🙂' — correct
// Old broken way:
console.log(emoji.charCodeAt(0)); // 55357 — just the high surrogate!
console.log(String.fromCharCode(55357)); // '' — broken lone surrogate!
// Lone surrogate — malformed but JS allows it
const lone = '\uD800'; // high surrogate with no pair
console.log(lone.length); // 1 — but it's technically malformed
• Always use
codePointAt() and String.fromCodePoint() for emoji and non-BMP characters• NOT
charCodeAt() and String.fromCharCode()• The charCode versions only understand 2-byte code units and return half a surrogate pair for emoji
• codePoint versions correctly handle the 4-byte case
A code point ≠ grapheme cluster. A grapheme cluster is what a user perceives as one character — the smallest unit of text that has meaning to a user.
'é' can be TWO different strings: U+00E9 (single code point, precomposed) OR U+0065 U+0301 (e + combining accent = 2 code points). Both render identically but have different .length and different === result!
Intl.Segmenter (ES2022) is the correct API to split strings into grapheme clusters.// Two ways to write 'é' — identical visually, different in JS
const e1 = '\u00E9'; // é as single code point (precomposed)
const e2 = 'e\u0301'; // e + combining accent (decomposed)
console.log(e1 === e2); // false — different code points!
console.log(e1.length); // 1
console.log(e2.length); // 2 — e + accent = 2 code units
// Visual comparison:
console.log(e1); // é
console.log(e2); // é (looks same!)
// Family emoji — 7 code points, 11 code units!
const family = '👨👩👧👦';
console.log(family.length); // 11
console.log([...family].length); // 7 — code points via iterator
// Still wrong for grapheme count — correct:
if (typeof Intl.Segmenter !== 'undefined') {
const segmenter = new Intl.Segmenter();
const graphemes = [...segmenter.segment(family)];
console.log(graphemes.length); // 1 — one grapheme cluster!
}
// Normalize to compare strings with combining characters
console.log(e1.normalize('NFC') === e2.normalize('NFC')); // true ✅
// NFC = Canonical Decomposition, followed by Canonical Composition
normalize('NFC') or normalize('NFD')) is critical for user input comparison. If one user types 'é' as a single code point and another composes it with accent, plain === fails. Normalize both strings to the same form before comparing.String[index] / charAt(): returns the code UNIT at that index — may be half a surrogate pair. String iterator (for...of, spread): iterates CODE POINTS — correctly handles surrogate pairs.
const text = 'Hi 🙂!';
// Index access — code units (broken for emoji)
console.log(text[0]); // 'H' — fine, BMP
console.log(text[3]); // broken lone high surrogate!
console.log(text[4]); // broken lone low surrogate!
console.log(text.length); // 6 (H i space 🙂[2 units] !)
// for...of — code points (correct for emoji)
for (const char of text) {
console.log(char); // H, i, ' ', 🙂, ! — 5 items, emoji as one ✅
}
// Spread — code points array
const chars = [...text];
console.log(chars); // ['H', 'i', ' ', '🙂', '!']
console.log(chars.length); // 5
// Regex: with /u flag — Unicode aware
console.log(/./u.test('🙂')); // true — matches full emoji as one char
console.log(/./.test('🙂')); // true — matches only first code unit
console.log('🙂'.match(/./gu).length); // 1 — one full emoji ✅
console.log('🙂'.match(/./g).length); // 2 — two half-surrogates ❌
// Reverse a string correctly
const str = 'Hello 🙂';
const wrong = str.split('').reverse().join(''); // breaks emoji!
const right = [...str].reverse().join(''); // correct code points
console.log(wrong); // garbled emoji!
console.log(right); // '🙂 olleH' ✅
• Use
for...of or [...string] when you need to iterate over visible characters (handles surrogate pairs)• Use
Intl.Segmenter when you truly need grapheme clusters (handles combining chars, emoji sequences)• Never use string[index] for non-ASCII text
String comparison (===): V8 checks pointer equality first (O(1) for interned strings), then length, then content. This is why interned strings compare lightning-fast.
'hello' === 'hello' is pointer comparison (O(1)). Runtime strings (from templates, user input) may NOT be interned — they use content comparison. V8's comparison short-circuits: different lengths are immediately false without scanning characters.// Interned strings — same literal = same object
const a = 'hello';
const b = 'hello';
// V8 uses pointer comparison — O(1)!
console.log(a === b); // true — same interned object
// Runtime strings — may NOT be interned
const c = 'hel' + 'lo'; // V8 may or may not intern this
const d = ['h','e','l','l','o'].join(''); // probably not interned
console.log(c === d); // true — but uses content comparison O(n)
// Slice is zero-copy — fast!
const longString = 'x'.repeat(100000);
console.time('slice');
const view = longString.slice(0, 1000); // SlicedString — no copy
console.timeEnd('slice');
// substring/slice comparison:
// slice(start, end) — negative indices count from end
// substring(start, end) — negative becomes 0, swaps if start > end
const s = 'Hello World';
console.log(s.slice(-5)); // 'World' — last 5 chars
console.log(s.substring(-5, 5)); // 'Hello' — negative → 0
// String deduplication for large data sets
const strings = Array.from({ length: 10000 }, () => 'same-value');
// Each created by runtime code — may not be interned
// Deduplicate with Map if memory matters:
const intern = new Map();
function dedup(str) {
if (!intern.has(str)) intern.set(str, str);
return intern.get(str);
}
Lo kar liya — Key Points:
- ✅ JS strings are sequences of UTF-16 code UNITS (2 bytes each) — not characters or code points
- ✅ BMP characters (U+0000 to U+FFFF) use 1 code unit. Supplementary (emoji, etc.) use 2 code units (surrogate pair)
- ✅
.lengthcounts code UNITS — emoji shows length 2 because it uses a surrogate pair - ✅ Use
for...ofor spread[...str]to iterate code POINTS — handles emoji correctly - ✅ Use
Intl.Segmenterfor true grapheme clusters — handles combining characters and emoji sequences - ✅ Normalize strings before comparing:
e1.normalize('NFC') === e2.normalize('NFC')handles precomposed vs decomposed forms
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login