Topics

Jump to a topic.

Search questions

Find a question by title, tag, or summary.

How do you iterate over the characters a user sees?

Questions in this chapter (5)
  • Draft
  • medium
  • Askedsometimes
  • Verified October 2026

Answer

There are three levels, and each needs a different tool:

Level Example How to iterate
UTF-16 code unit 0xD83D index loop, split("")
Code point U+1F600 for...of, spread, Array.from, codePointAt
Grapheme cluster ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง (5 code points) Intl.Segmenter

The string iterator in ECMA-262 yields one code point at a time, so surrogate pairs stay together. That is enough for a plain emoji such as ๐Ÿ˜€.

It is not enough for everything a user sees as one character. Unicode UAX #29 defines grapheme clusters as user-perceived characters. Examples that are several code points:

  • e + U+0301 (combining acute accent), which renders as รฉ.
  • A flag such as ๐Ÿ‡ฎ๐Ÿ‡ณ: two regional-indicator code points.
  • A family emoji ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง: three emoji joined by zero-width joiners (U+200D).
  • An emoji with a skin-tone modifier, ๐Ÿ‘๐Ÿฝ.

Intl.Segmenter (ECMA-402) implements these boundaries. Create it with granularity: "grapheme", call segment(text), and iterate: each item is { segment, index, input }, where index is the UTF-16 offset. Use it for truncation, caret movement, character counters, and reversing text. The default granularity is "grapheme". "word" adds isWordLike, and "sentence" splits sentences.

Boundaries come from the engine's Unicode data (ICU in V8), so results can change when Unicode or the engine updates. Don't persist counts as exact data.

Example

Output below is from Node 24.19 (ICU 78.3, Unicode 17.0).

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const graphemes = (text) => Array.from(segmenter.segment(text), (part) => part.segment);

for (const text of ["๐Ÿ˜€", "eฬ", "๐Ÿ‡ฎ๐Ÿ‡ณ", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง"]) {
  console.log(text.length, [...text].length, graphemes(text).length);
}
// โ†’ 2 1 1
// โ†’ 2 2 1
// โ†’ 4 2 1
// โ†’ 8 5 1

const text = "Hi ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง there";
console.log([...text].slice(0, 5).join("")); // โ†’ Hi ๐Ÿ‘จโ€ (family cut apart)
console.log(graphemes(text).slice(0, 5).join("")); // โ†’ Hi ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง 
console.log(graphemes(text).reverse().join("")); // โ†’ ereht ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง iH

Pitfalls

  • Treating [...s].length as "characters". It counts code points; a family emoji is 5, a flag is 2, a decomposed รฉ is 2.
  • Truncating with slice or a code point array can leave a half-built emoji (a man followed by a dangling joiner) or a base letter without its accent.
  • Building a new Intl.Segmenter inside a loop. Construct once and reuse it.
  • Assuming grapheme counting is locale-free arithmetic. It uses Unicode data that is updated over time, so two engines (or versions) can disagree on newer emoji.

Follow-ups

What does segmenter.segment(text) return?
A lazy, iterable Segments object, not an array. Iterate it with for...of or Array.from. It also has containing(index) to find the segment at a UTF-16 index.
How do you split text into words?
Use granularity: "word". Each segment has isWordLike, which is true for words and numbers and false for spaces and punctuation. It also handles languages written without spaces, which split(" ") cannot.

Exercises

Count what the user sees

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const name = "Zoeฬ ๐Ÿ‡ซ๐Ÿ‡ท";
console.log(name.length, [...name].length, [...segmenter.segment(name)].length);

Find the segment at an index

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
console.log(segmenter.segment("a๐Ÿ˜€").containing(2));