AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

10 Strings, Unicode, regular expressions and parsing / Lesson 1 of 5

Unicode and how JavaScript strings really work

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

Why is the length of an emoji 2 in JavaScript?

JavaScript strings are sequences of UTF-16 code units, not characters, and length counts code units. An emoji such as the grinning face (U+1F600) lies above U+FFFF, so it is stored as a surrogate pair of two code units. Count code points with [...str].length, and count what a reader sees as one character with Intl.Segmenter at grapheme granularity.

A JavaScript string is a sequence of UTF-16 code units, not of characters — and every string bug that looks impossible ("length is 2 for one emoji", "reverse() broke my text", "these two identical strings are not equal") comes from that gap. This lesson gives you the three layers — code units, code points, grapheme clusters — the APIs that work at each, normalisation, case mapping and comparison across languages, and the rules for which layer a given task needs. It matters for anything that stores names, counts characters, truncates text, sorts, or searches.

Three layers

LayerWhat it isHow JavaScript exposes it
Code unit16-bit number; strings are made of thesestr.length, str[i], charCodeAt, charAt, slice indices
Code pointa Unicode scalar value, U+0000–U+10FFFF; one or two code unitscodePointAt, String.fromCodePoint, for…of, [...str], \u{1F600}, the regex u flag
Grapheme clusterwhat a reader sees as one character (base + combining marks, emoji sequences)Intl.Segmenter (granularity grapheme) — nothing else
const s = "😀";           // U+1F600
s.length;                  // 2 — two code units (a surrogate pair: 0xD83D 0xDE00)
[...s].length;             // 1 — one code point
s.codePointAt(0).toString(16);   // "1f600";  s.charCodeAt(0) is 0xd83d — the high surrogate
String.fromCodePoint(0x1f600) === s;   // true; String.fromCharCode needs both units

const family = "👨‍👩‍👧";      // man + ZWJ + woman + ZWJ + girl: 5 code points, 8 code units, ONE grapheme
[...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(family)].length;   // 1

Code points above U+FFFF (emoji, many CJK characters, mathematical symbols) take two code units, a surrogate pair. Indexing into the middle of one gives a lone surrogate — an invalid string that prints as �. "abc😀".slice(0, 4) cuts the emoji in half; [...str].slice(0, n).join("") truncates by code point; only a segmenter truncates by what the user sees.

Iteration is by code point

for (const ch of str), [...str], Array.from(str) and the regex u flag all step by code point. str.split(""), str[i] and a for loop over length step by code unit. Use the former for anything that must treat an emoji or a rare character as a unit; use length/indices only for ASCII-known data or when you truly mean code units (storage limits in UTF-16).

Combining marks and normalisation

"é" can be one code point (U+00E9, precomposed) or two (e U+0065 + combining acute U+0301). They render identically, compare unequal, and have different lengths:

"é" === "é";                        // false
"é".normalize("NFC") === "é".normalize("NFC");   // true — NFC composes; NFD decomposes

Normalise (NFC is the usual choice) before comparing, hashing, deduplicating or storing user text; keyboards, operating systems and copy-paste produce both forms. NFKC/NFKD additionally fold compatibility characters (fi → fi, full-width digits) — for search matching, not for storage.

Case mapping

toUpperCase/toLowerCase are Unicode-aware and can change length: "straße".toUpperCase() is "STRASSE". They are locale-independent; toLocaleUpperCase("tr") turns i into İ (dotted capital I). Case-insensitive comparison in general is a.toLowerCase() === b.toLowerCase() for ordinary text, or Intl.Collator with sensitivity: "accent"/"base" for user-facing matching. Note .toLowerCase() on user input is not a normaliser — normalise too.

Comparing and sorting text

< and sort() compare code units — uppercase before lowercase, accents sorted to the end, "Z" < "a". For human order:

names.sort((a, b) => a.localeCompare(b));                          // locale-aware
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true });   // reuse: much faster than localeCompare in a loop
names.sort(collator.compare);                                      // "file2" before "file10"; "a" equal to "A" and "á"

numeric: true sorts embedded numbers naturally; sensitivity: "base" ignores case and accents. Always pass a locale in servers so results do not depend on the machine's default.

Segmentation

Intl.Segmenter (Node 16+, all modern browsers) splits by grapheme, word or sentence according to Unicode rules — the only correct way to count visible characters, to truncate "at 20 characters", or to split words in languages without spaces:

const words = [...new Intl.Segmenter("en", { granularity: "word" }).segment(text)].filter((s) => s.isWordLike).map((s) => s.segment);

str.split(" ") is fine for ASCII prose and wrong for Thai, Japanese, or text with punctuation.

Immutability and building

Strings are immutable values: every method returns a new string; str[0] = "x" does nothing. Repeated += in a loop is fine in modern engines for moderate sizes (they use ropes internally); for very large output collect parts in an array and join("") once, or write to a stream. Comparing strings with === is by value and O(n) worst case; interning is the engine's business.

Escapes and literals

"\n" "\t" "\\" "é" "\u{1F600}" "\x41"; template literals keep raw newlines; String.raw\\n\` keeps the backslash. A string can contain lone surrogates and other invalid sequences; str.isWellFormed()/toWellFormed() (Node 20) detect and repair them — on 16, check with a regex for unpaired surrogates if it matters (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/`).

Common mistakes

  • Counting or truncating with length/slice on text that may contain emoji or non-BMP characters.
  • str.split("").reverse().join("") — breaks surrogate pairs and combining sequences.
  • Comparing user text without normalize("NFC").
  • sort() on names; toLowerCase() as a full case-fold; assuming case mapping preserves length.
  • Splitting words on spaces for non-Latin text; regexes without the u flag on Unicode text (next lessons).

Interview angle

  • "Why is '😀'.length 2?" Strings are UTF-16 code units; the emoji is a surrogate pair — one code point, two units.
  • "How do you count characters correctly?" Code points with [...str].length; visible characters with Intl.Segmenter graphemes.
  • "Why are two visually identical strings unequal?" Precomposed versus combining sequences — normalise with NFC before comparing.
  • "How do you sort strings for humans?" localeCompare or a reused Intl.Collator with numeric/sensitivity options.
  • "Can toUpperCase change a string's length?" Yes — ß → SS.

Key takeaways

  • Code units (length, indices) ≠ code points (for…of, spread, codePointAt) ≠ graphemes (Intl.Segmenter); pick the layer the task needs.
  • Non-BMP characters are surrogate pairs; never slice through them; reverse and truncate by code point or grapheme.
  • Normalise (NFC) before comparing or storing; NFKC for search folding.
  • Case mapping is Unicode-aware and length-changing; locale matters (tr).
  • Sort and compare with Intl.Collator/localeCompare and an explicit locale; segment words with Intl.Segmenter.

Common questions

How do you count characters in a JavaScript string correctly?

It depends on what "character" means. str.length counts UTF-16 code units; [...str].length counts code points, so an emoji counts once; and Intl.Segmenter with granularity: "grapheme" counts what a reader sees, so a family emoji of three people joined by zero-width joiners, five code points in all, counts as one.

Why are two identical-looking strings not equal in JavaScript?

Accented letters can be stored two ways: "é" as one precomposed code point (U+00E9) or as e followed by a combining acute accent (U+0301). They render identically but compare unequal and have different lengths. Call normalize("NFC") on both before comparing, hashing or storing user text.

How do you reverse a string with emoji in JavaScript?

Not with str.split("").reverse().join(""), which splits by code unit and breaks surrogate pairs into invalid characters. [...str].reverse().join("") reverses by code point and keeps a single emoji intact; only splitting into graphemes with Intl.Segmenter also keeps combining marks and multi-part emoji sequences together.

How do you sort strings alphabetically in JavaScript?

Not with a bare sort(), which compares UTF-16 code units, so "Z" sorts before "a" and accented letters go to the end. Use a.localeCompare(b), or create one Intl.Collator and pass its compare, which is much faster in a loop; the numeric: true option sorts "file2" before "file10".

Mark as readSign in to keep your place.

JavaScript Unicode Strings: UTF-16, Emoji Length and NFC

JavaScript strings are UTF-16 code units, so an emoji has length 2. Code points vs grapheme clusters, Intl.Segmenter, normalize NFC, case mapping and sorting.

  • Course: JavaScript study plan
  • Module: Strings, Unicode, regular expressions and parsing
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: Node 16

Why is the length of an emoji 2 in JavaScript?

JavaScript strings are sequences of UTF-16 code units, not characters, and length counts code units. An emoji such as the grinning face (U+1F600) lies above U+FFFF, so it is stored as a surrogate pair of two code units. Count code points with [...str].length, and count what a reader sees as one character with Intl.Segmenter at grapheme granularity.

Lesson

A JavaScript string is a sequence of UTF-16 code units, not of characters — and every string bug that looks impossible ("length is 2 for one emoji", "reverse() broke my text", "these two identical strings are not equal") comes from that gap. This lesson gives you the three layers — code units, code points, grapheme clusters — the APIs that work at each, normalisation, case mapping and comparison across languages, and the rules for which layer a given task needs. It matters for anything that stores names, counts characters, truncates text, sorts, or searches.

Three layers

LayerWhat it isHow JavaScript exposes it
Code unit16-bit number; strings are made of thesestr.length, str[i], charCodeAt, charAt, slice indices
Code pointa Unicode scalar value, U+0000–U+10FFFF; one or two code unitscodePointAt, String.fromCodePoint, for…of, [...str], \u{1F600}, the regex u flag
Grapheme clusterwhat a reader sees as one character (base + combining marks, emoji sequences)Intl.Segmenter (granularity grapheme) — nothing else
const s = "😀";           // U+1F600
s.length;                  // 2 — two code units (a surrogate pair: 0xD83D 0xDE00)
[...s].length;             // 1 — one code point
s.codePointAt(0).toString(16);   // "1f600";  s.charCodeAt(0) is 0xd83d — the high surrogate
String.fromCodePoint(0x1f600) === s;   // true; String.fromCharCode needs both units

const family = "👨‍👩‍👧";      // man + ZWJ + woman + ZWJ + girl: 5 code points, 8 code units, ONE grapheme
[...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(family)].length;   // 1

Code points above U+FFFF (emoji, many CJK characters, mathematical symbols) take two code units, a surrogate pair. Indexing into the middle of one gives a lone surrogate — an invalid string that prints as �. "abc😀".slice(0, 4) cuts the emoji in half; [...str].slice(0, n).join("") truncates by code point; only a segmenter truncates by what the user sees.

Iteration is by code point

for (const ch of str), [...str], Array.from(str) and the regex u flag all step by code point. str.split(""), str[i] and a for loop over length step by code unit. Use the former for anything that must treat an emoji or a rare character as a unit; use length/indices only for ASCII-known data or when you truly mean code units (storage limits in UTF-16).

Combining marks and normalisation

"é" can be one code point (U+00E9, precomposed) or two (e U+0065 + combining acute U+0301). They render identically, compare unequal, and have different lengths:

"é" === "é";                        // false
"é".normalize("NFC") === "é".normalize("NFC");   // true — NFC composes; NFD decomposes

Normalise (NFC is the usual choice) before comparing, hashing, deduplicating or storing user text; keyboards, operating systems and copy-paste produce both forms. NFKC/NFKD additionally fold compatibility characters (fi → fi, full-width digits) — for search matching, not for storage.

Case mapping

toUpperCase/toLowerCase are Unicode-aware and can change length: "straße".toUpperCase() is "STRASSE". They are locale-independent; toLocaleUpperCase("tr") turns i into İ (dotted capital I). Case-insensitive comparison in general is a.toLowerCase() === b.toLowerCase() for ordinary text, or Intl.Collator with sensitivity: "accent"/"base" for user-facing matching. Note .toLowerCase() on user input is not a normaliser — normalise too.

Comparing and sorting text

< and sort() compare code units — uppercase before lowercase, accents sorted to the end, "Z" < "a". For human order:

names.sort((a, b) => a.localeCompare(b));                          // locale-aware
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true });   // reuse: much faster than localeCompare in a loop
names.sort(collator.compare);                                      // "file2" before "file10"; "a" equal to "A" and "á"

numeric: true sorts embedded numbers naturally; sensitivity: "base" ignores case and accents. Always pass a locale in servers so results do not depend on the machine's default.

Segmentation

Intl.Segmenter (Node 16+, all modern browsers) splits by grapheme, word or sentence according to Unicode rules — the only correct way to count visible characters, to truncate "at 20 characters", or to split words in languages without spaces:

const words = [...new Intl.Segmenter("en", { granularity: "word" }).segment(text)].filter((s) => s.isWordLike).map((s) => s.segment);

str.split(" ") is fine for ASCII prose and wrong for Thai, Japanese, or text with punctuation.

Immutability and building

Strings are immutable values: every method returns a new string; str[0] = "x" does nothing. Repeated += in a loop is fine in modern engines for moderate sizes (they use ropes internally); for very large output collect parts in an array and join("") once, or write to a stream. Comparing strings with === is by value and O(n) worst case; interning is the engine's business.

Escapes and literals

"\n" "\t" "\\" "é" "\u{1F600}" "\x41"; template literals keep raw newlines; String.raw\\n\` keeps the backslash. A string can contain lone surrogates and other invalid sequences; str.isWellFormed()/toWellFormed() (Node 20) detect and repair them — on 16, check with a regex for unpaired surrogates if it matters (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/`).

Common mistakes

  • Counting or truncating with length/slice on text that may contain emoji or non-BMP characters.
  • str.split("").reverse().join("") — breaks surrogate pairs and combining sequences.
  • Comparing user text without normalize("NFC").
  • sort() on names; toLowerCase() as a full case-fold; assuming case mapping preserves length.
  • Splitting words on spaces for non-Latin text; regexes without the u flag on Unicode text (next lessons).

Interview angle

  • "Why is '😀'.length 2?" Strings are UTF-16 code units; the emoji is a surrogate pair — one code point, two units.
  • "How do you count characters correctly?" Code points with [...str].length; visible characters with Intl.Segmenter graphemes.
  • "Why are two visually identical strings unequal?" Precomposed versus combining sequences — normalise with NFC before comparing.
  • "How do you sort strings for humans?" localeCompare or a reused Intl.Collator with numeric/sensitivity options.
  • "Can toUpperCase change a string's length?" Yes — ß → SS.

Key takeaways

  • Code units (length, indices) ≠ code points (for…of, spread, codePointAt) ≠ graphemes (Intl.Segmenter); pick the layer the task needs.
  • Non-BMP characters are surrogate pairs; never slice through them; reverse and truncate by code point or grapheme.
  • Normalise (NFC) before comparing or storing; NFKC for search folding.
  • Case mapping is Unicode-aware and length-changing; locale matters (tr).
  • Sort and compare with Intl.Collator/localeCompare and an explicit locale; segment words with Intl.Segmenter.

Common questions

How do you count characters in a JavaScript string correctly?

It depends on what "character" means. str.length counts UTF-16 code units; [...str].length counts code points, so an emoji counts once; and Intl.Segmenter with granularity: "grapheme" counts what a reader sees, so a family emoji of three people joined by zero-width joiners, five code points in all, counts as one.

Why are two identical-looking strings not equal in JavaScript?

Accented letters can be stored two ways: "é" as one precomposed code point (U+00E9) or as e followed by a combining acute accent (U+0301). They render identically but compare unequal and have different lengths. Call normalize("NFC") on both before comparing, hashing or storing user text.

How do you reverse a string with emoji in JavaScript?

Not with str.split("").reverse().join(""), which splits by code unit and breaks surrogate pairs into invalid characters. [...str].reverse().join("") reverses by code point and keeps a single emoji intact; only splitting into graphemes with Intl.Segmenter also keeps combining marks and multi-part emoji sequences together.

How do you sort strings alphabetically in JavaScript?

Not with a bare sort(), which compares UTF-16 code units, so "Z" sorts before "a" and accented letters go to the end. Use a.localeCompare(b), or create one Intl.Collator and pass its compare, which is much faster in a loop; the numeric: true option sorts "file2" before "file10".

Exercises

Code units, code points, graphemes

The input is a list of hexadecimal code points; build the string with String.fromCodePoint. Print length=<code units> codePoints=<[...s].length> graphemes=<Intl.Segmenter grapheme count>; units=<each charCodeAt in hex>; codePoint0=<hex> charCode0=<hex> isPair=<first code point above 0xFFFF>; reverseNaive=<code points of s.split("").reverse().join("")> and reverseByCodePoint=<code points of [...s].reverse().join("")> (both as hex code points via the starter's hex); and sliceHalf=<hex of s.slice(0, 1)> at(-1)=<hex of s.at(-1)> last=<hex of [...s].at(-1)>.

Example: 1F600 61 (😀a) →

length=3 codePoints=2 graphemes=2
units=d83d de00 61
codePoint0=1f600 charCode0=d83d isPair=true
reverseNaive=61 de00 d83d
reverseByCodePoint=61 1f600
sliceHalf=d83d at(-1)=61 last=61

The naive reverse produced two lone surrogates; slice(0, 1) cut the emoji in half.

Normalise, then compare like a human

Line 1 holds the same word twice — precomposed and decomposed (é as U+00E9 versus e + U+0301). Line 2 is a word list. Print equal=<a === b> normalizedEqual=<NFC forms equal> lengths=<a.length>,<b.length> nfdLengths=<NFD lengths>; sortDefault=<[...words].sort()>; sortCollator=<sorted with new Intl.Collator("en", { numeric: true }).compare>; baseEqual(Apple,apple)=<bool> baseEqual(e,é)=<bool> using a collator with sensitivity: "base"; and upper=<"straße".toUpperCase()> lengthChange=<before>-><after> turkishI=<"i".toLocaleUpperCase("tr") code point in hex>.

Example: café café / banana Apple apple Éclair zebra file10 file2 →

equal=false normalizedEqual=true lengths=4,5 nfdLengths=5,5
sortDefault=Apple apple banana file10 file2 zebra Éclair
sortCollator=apple Apple banana Éclair file2 file10 zebra
baseEqual(Apple,apple)=true baseEqual(e,é)=true
upper=STRASSE lengthChange=6->7 turkishI=130

Default sort put the accented word last and file10 before file2; the collator fixed both.

In this module: Strings, Unicode, regular expressions and parsing

← Checkpoint — Modules and Node · The string toolbox, template literals, tagged templates and Intl formatting →