Skip to content
Chapter 1Lesson 4

What .length actually counts

How JavaScript models strings as Unicode — Intl.Segmenter to count what a user sees, normalization to keep look-alike text comparable.

A user types 280 emojis into a bio field with a “280-character limit” and gets a “too long” error. Another signs up with an accented name, and the duplicate check misses an existing row that looks identical on screen. Both bugs trace to the same root: string.length counts UTF-16 storage chunks, not the characters a user perceives. This lesson shows which of the three available counts to reach for, and how to compare two strings a user reads as identical.

Predict the output of these three lines. Two won’t match what the strings look like on screen.

Predict what this program prints, then press Check.

console.log('hello'.length);
console.log('🇺🇸'.length);
console.log('👨‍👩‍👧‍👦'.length);

The lesson goes beyond “watch out for emojis.” .length is a serialization detail : it tells you how the engine stored the string, not how a human reads it.

Code units, code points, or grapheme clusters

Section titled “Code units, code points, or grapheme clusters”

JavaScript has no single “length of a string.” It has three, each answering a different question, so the useful question is not “how long is this string?” but “do I need code units, code points, or grapheme clusters ?”

The same input, measured all three ways:

const family = '👨‍👩‍👧‍👦';
family.length; // 11
[...family].length; // 7
[...new Intl.Segmenter('en', { granularity: 'grapheme' })
.segment(family)].length; // 1

Reach for each one when:

  • Code units: string.length. Use it when the value is a key, an index back into the same string, or a byte budget for a serialized payload. Fast, allocates nothing, correct for ASCII, and wrong for anything the user sees.
  • Code points: [...string].length (or Array.from(string).length). A code point is one entry in the Unicode catalogue, the number Unicode assigns to a character regardless of how many 16-bit code units it takes to store. Spreading a string iterates by code point, so surrogate pairs collapse to one. Closer to a human count than .length, but still wrong for any character built from joined sequences: the family above is seven code points but one cluster on screen.
  • Grapheme clusters: new Intl.Segmenter(locale, { granularity: 'grapheme' }), then count the segments. This is “how many characters does the user see,” and what any length check on a user-facing field needs.
Input .length (code units) [...str].length (code points) Intl.Segmenter (grapheme clusters)
'café' (combining acute) 5 5 4
'🇺🇸' (US flag) 4 2 1
'👨‍👩‍👧‍👦' (family) 11 7 1
Three inputs, each counted three ways, ordered by how far the counts diverge: a combining mark, a flag, then joined emoji.

Each step up in fidelity, code units to code points to grapheme clusters, handles another class of input the cruder count gets wrong. On ASCII all three agree, which is why .length looks fine on 'hello' and then fails without warning the moment a user pastes an emoji or an accented character.

When validation arrives later, note that Zod’s .length() constraint counts code units, so a user-facing length check needs a custom refinement that runs the segmenter.

Count grapheme clusters with Intl.Segmenter

Section titled “Count grapheme clusters with Intl.Segmenter”

Intl.Segmenter ships in every browser and Node version this course targets, so you can use it directly. The one-liner to memorize:

const countCharacters = (input: string): number =>
[...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(input)].length;
countCharacters('🇺🇸'); // 1
countCharacters('👨‍👩‍👧‍👦'); // 1
countCharacters('café'); // 4

The constructor takes a locale and a granularity. The locale matters for scripts where grapheme rules differ (Thai, Khmer, Devanagari); for Latin-script text the boundaries come out the same either way, but the constructor still requires one. 'grapheme' segments by user-perceived character; 'word' and 'sentence' do the same for related jobs, and word segments carry an isWordLike flag so you can skip punctuation.

.segment(input) returns an iterable of { segment, index, ... } objects, one per cluster, with no .length shortcut. Spreading into an array and reading its length is the idiomatic count.

The same Intl.* namespace is the platform-native internationalization surface the course leans on: it also ships Intl.NumberFormat, Intl.Collator for locale-aware sorting, and Intl.DateTimeFormat.

There is a second way two strings can mislead you: they look identical on screen but compare as different. In this snippet the two literals render the same, yet the equality check fails and the two length checks disagree:

const a = 'café'; // precomposed: 'c', 'a', 'f', 'é'
const b = 'café'; // decomposed: 'c', 'a', 'f', 'e' + combining acute
a === b; // false
a.length; // 4
b.length; // 5

A user would type these as the same word, but they are different sequences of Unicode code points. The first uses a single precomposed é, one code point that includes the accent. The second uses a plain e followed by a combining acute accent, two code points the renderer paints as one visual cluster. The rule from the “What === compares” lesson still holds: === compares primitives byte for byte, and these bytes differ.

The fix is one method call:

a.normalize('NFC') === b.normalize('NFC'); // true

.normalize('NFC') collapses both forms to the same canonical sequence. There are four normalization forms, each with a short rule of thumb:

  • NFC (Canonical Composition): combine characters into their precomposed form. The default for storage, comparison, and search, and the form this lesson uses.
  • NFD (Canonical Decomposition): split precomposed characters into base letter plus combining marks. Useful for accent-insensitive search, where you decompose and then strip the marks.
  • NFKC / NFKD (Compatibility forms): collapse characters that look similar but are semantically distinct, so the ligature becomes fi and full-width digits become ASCII digits. Useful for fuzzy matching at a search boundary, but the wrong default for storage because they lose information.

One rule keeps this from becoming a maintenance burden: normalize once, at the database write boundary. If every value in the table is already NFC, every downstream comparison and length check works against a canonical form for free, with no .normalize('NFC') scattered across call sites. So if you find yourself reaching for .normalize inside a comparison helper or a search function, walk back to the seam where the value entered the system and normalize it there instead, alongside your input validation.

Your database column will not do this for you: Postgres stores whatever bytes you hand it, so the normalization belongs in the schema or action handler, before the row hits the database.

The String.prototype methods you reach for

Section titled “The String.prototype methods you reach for”

String.prototype is huge, and most of it is legacy. A small set of methods covers daily work:

  • includes / startsWith / endsWith: substring tests. Reach for these over indexOf(needle) !== -1, which reads as “where is it?” when the question is “is it there?”
  • at(-1): last-character access, cleaner than string[string.length - 1]. Negative indices count from the end.
  • slice(start, end): substring extraction, and your default.
  • split / join: the boundary between a string and an array of segments. split(sep) breaks the string apart, join(sep) reassembles it.
  • replaceAll(needle, replacement): replace every occurrence of a literal string, with no replace(/needle/g, ...) regex boilerplate.
  • trim / trimStart / trimEnd: whitespace cleanup at input boundaries. Pair with the empty-string guard from the previous lesson when converting form input.
  • padStart / padEnd: fixed-width formatting, mostly in logs and CLI output.
  • localeCompare(other, locale, options): locale-aware comparison, the right answer for any user-visible alphabetical sort. < and > compare by code unit, which orders accented characters in positions no human would call alphabetical.
  • normalize(form): the boundary tool from the previous section.

The legacy methods you’ll meet in older code or AI suggestions are substr and substring (use slice), the escape / unescape globals (use encodeURIComponent / decodeURIComponent), and the 1990s HTML-wrapping methods like bold and italics (JSX owns markup here). When you need the full surface, MDN has it.

Implement countCharacters(input) so it returns the number of characters the user perceives, the count a bio field with a character limit should enforce.

Implement countCharacters(input) using Intl.Segmenter with granularity: 'grapheme'. The tests cover ASCII, emoji built from surrogate pairs, joined emoji sequences, combining marks, and the empty string. If you use .length or the spread form, the emoji tests will fail; only the segmenter passes all six.

    The flag and family emoji tests fail the moment you reach for .length or the spread form, just as they did in the table earlier. The combining-mark test then catches [...str].length, which counts the accent separately.