Strings and text processing · Lesson 40 of 77 · concept
Comparing text people actually typed
About 50 minutes.
By the end of this lesson you can
- Compare two pieces of typed text fairly by trimming the ends and putting both into one case.
- Explain what .length actually counts, and why one emoji counts as 2.
- Step through text by character with the spread syntax instead of by position.
- Use normalize() so the same accented letter typed two ways compares as equal.
- Name a case where lower-casing is not enough, and say what you would do about it.
The same text, typed differently
Why it matters
A user types " Ada " into a search box and nothing is found, because the stored value is "ada". You lower-case both and it works. Then a French user searches for a café and still finds nothing, even though the letters on both screens are identical.
That second one is not a bug in your code exactly — it is a fact about how text is stored. There is more than one way to write an accented letter, and two strings that LOOK the same can be different sequences of characters. This lesson is about knowing that, and about the one line of code that deals with it.
The general rule you are building towards: never compare raw user text with ===. Put both sides through the same clean-up first, and do it at the boundary where the text arrives, so the rest of your program only ever sees text in one agreed form.
Case, and the trim that has to come with it
Explanation
`toLowerCase()` and `toUpperCase()` each return a new string in one case. They are the obvious first step for "did they type the same word", and they are the step everyone remembers.
The step people forget is trim. Whitespace at the ends is invisible on screen and fatal to ===, so a fair comparison needs both: `a.trim().toLowerCase() === b.trim().toLowerCase()`.
Lower-case rather than upper-case is the common convention, and either is fine as long as BOTH sides get the same treatment. What matters is that comparison is a decision you make once, in one function, rather than five slightly different attempts scattered through the code.
What .length actually counts
Explanation
You have been told that `.length` is the number of characters. That was a simplification, and here is the honest version: it is the number of 16-bit units used to store the text. For English text those are the same number, which is why the simplification survives so long.
For characters outside that range — emoji, many scripts, some symbols — one character takes TWO units. So the grinning-face emoji, written in code as "\u{1F600}", has a .length of 2 while being unmistakably one character. Split a string in the middle of a pair and you get half a character, which displays as a meaningless box.
The practical fix is to stop using positions when the text might not be plain English. `[...text]` — the spread syntax from module 4 — steps through text by whole character rather than by storage unit, so `[...text].length` counts what a person would count, and `[...text][3]` gives you a whole character.
`codePointAt(i)` gives the full numeric value of the character starting at position i, where `charCodeAt(i)` gives only the 16-bit unit — for the emoji that is 128512 versus 55357, and the second number is not any character at all. You will rarely need either, but seeing them once explains what is going on underneath.
Two ways to write the same accented letter
Explanation
Unicode allows an accented letter to be written two ways: as one combined character (an e-with-acute, written in code as \u00e9), or as a plain e followed by a separate combining accent mark (\u0301). Both display identically. Neither is wrong. Which one you get depends on the keyboard, operating system and application the text came from — Apple systems have historically favoured the second, most others the first.
To JavaScript these are simply different strings — different lengths, and === says false. That is the café bug in one sentence.
`normalize()` fixes it. It rewrites text into a single agreed form, and calling it with no arguments gives you NFC, the composed form, which is the sensible default. Put both sides of a comparison through it and the two spellings become the same string.
You do not need to understand the four normalisation forms to use this. Learn the habit — `text.trim().normalize().toLowerCase()` at the boundary — and read the details the day you have a reason to.
Lower-casing is not a universal equality test
A common mistake
Case conversion is not reversible, and not every language has one letter per letter. The German sharp s, written in code as \u00df, upper-cases to the two letters SS — so "Stra\u00dfe".toUpperCase() has SEVEN characters where the original had six. Lower-case SS again and you get "ss", not the letter you started with.
The consequence is concrete: comparing "Stra\u00dfe" and "STRASSE" with toLowerCase() on both sides gives false. To a German reader those are the same word. To toLowerCase they are not, and no amount of trimming or normalising changes that.
There is a proper answer — full case folding, which Unicode defines for exactly this — and JavaScript does not expose it directly. There are also language-specific rules (Turkish handles the letter i differently), which is what `toLocaleLowerCase()` is for.
The honest position for now: trim, normalize and lower-case handles the overwhelming majority of real comparisons, and you should know that it is a very good approximation rather than a law. When you are matching names or identifiers across languages for real, that is the day to reach for a library that implements case folding properly.
Normalise once, at the edge
A mental model
Write one small function — call it `normaliseForComparison` — that does trim, normalize and lower-case, and use it everywhere text is compared, stored as a key, or de-duplicated.
Two things follow. Your comparison rule lives in one place, so improving it later is one edit rather than a search. And every value in the middle of your program is already in a known form, so you stop wondering at each step whether this particular string has been cleaned yet.
Keep the original text too. The clean version is for comparing and looking up; the version the user typed is what you show back to them. Throwing away their capitalisation because you needed to compare it is a small rudeness that users notice.
What to take away
Recap
Fair comparison of typed text is trim, then normalize, then lower-case — on both sides, in one shared function, at the boundary.
.length counts storage units, not characters: an emoji counts 2. Use [...text] to step through whole characters. And know that lower-casing is a very good approximation to equality, not a guarantee.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Two strings that look identical and are not
The café bug, reproduced deliberately. The escape codes are used so you can see exactly which characters are involved: \u0301 is a combining acute accent and \u00e9 is the single combined letter. On screen both strings read Café.
1const typed = "Cafe\u0301";2const stored = "Caf\u00e9";3console.log(typed.length, stored.length);4console.log(typed === stored);5console.log(typed.normalize() === stored.normalize());6console.log(typed.normalize().length);- line 1
Five characters: C, a, f, e, and a separate combining accent that renders on top of the e.
- line 2
Four characters: the last one is a single combined e-with-acute.
- line 4
false — and this is the whole bug. Two strings that display identically are not equal.
- line 5
true. normalize() with no argument gives the composed form, NFC, so both become the four-character version.
What it prints
5 4falsetrue4
Why .length says 5 for four characters
One short string containing an emoji, measured two ways. The point is not the emoji; it is that .length and 'how many characters a person sees' are different questions with different answers.
1const emoji = "hi \u{1F600}";2console.log(emoji.length);3console.log([...emoji].length);4console.log(emoji.codePointAt(3));5console.log(emoji.charCodeAt(3));6console.log([...emoji][3] === "\u{1F600}");- line 1
h, i, a space, and one emoji: four characters as a person counts them.
- line 2
5 — the emoji occupies two storage units, and .length counts units.
- line 3
4 — spreading steps by whole character, which is what you usually want.
- line 4
128512 is the emoji's real code point; 55357 on the next line is only the first half of the pair and is not a character on its own.
What it prints
5412851255357true
Where lower-casing fails
The German sharp s, which upper-cases into two letters and cannot come back. This is the example to remember when you are tempted to treat toLowerCase() as a universal equality test.
1console.log("Stra\u00dfe".toUpperCase());2console.log("Stra\u00dfe".length, "Stra\u00dfe".toUpperCase().length);3console.log("Stra\u00dfe".toLowerCase() === "STRASSE".toLowerCase());4console.log(" Ada ".trim().normalize().toLowerCase() === "ADA".toLowerCase());- line 1
One letter became two: the sharp s upper-cases to SS.
- line 2
So the string got LONGER. Case conversion does not preserve length.
- line 3
false. To a German reader these are the same word; to toLowerCase they are not, because you cannot get the sharp s back from SS.
- line 4
The ordinary case, working as expected — which is why the approximation is worth using despite line 3.
What it prints
STRASSE6 7falsetrue
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Both variables display as Café on screen. Predict all three lines — especially the second one — before running it.
const typed = "Cafe\u0301";const stored = "Caf\u00e9";console.log(typed.length);console.log(typed === stored);console.log(typed.normalize() === stored.normalize());
What does `"\u{1F600}".length` report? (That escape is one grinning-face emoji.)
Two names arrived from two different systems and you must decide whether they are the same name. Which test is the most reliable of these?
Write it yourself
Exercise · core · 7 tests
Decide whether two people typed the same thing
Write `sameText(a, b)` that returns `true` when two pieces of typed text should count as the same, and `false` otherwise. The same means: ignore whitespace at the ends, ignore capitalisation, and treat the two ways of writing an accented letter as equal. So `sameText(" Ada ", "ada")` is `true`, and so is `sameText("Cafe\u0301", "CAF\u00c9")` — those two strings hold different characters but read identically. Everything else stays significant. `sameText("a b", "ab")` is `false`: a space in the MIDDLE is part of the text, and nothing here removes it. Write the clean-up once, in its own small function, and call it for each side. Both sides must get exactly the same treatment or the comparison is not fair.
Returns a boolean. Two strings are equal when their trimmed, Unicode-normalised, lower-cased forms are identical. Whitespace inside the text and every non-whitespace difference remains significant. Works when either or both strings are empty.
Define sameText at the top level of your code — the tests call it by name.
Worth remembering
- How do you fairly compare two pieces of user-typed text?
a.trim().normalize().toLowerCase() === b.trim().normalize().toLowerCase(), with the clean-up in one shared function so both sides get identical treatment. Do it at the boundary, and keep the original for display.
- Why is one emoji's .length equal to 2?
length counts 16-bit storage units, not characters, and characters outside the basic range need two. [...text] steps through whole characters, so [...text].length counts what a person would.
- Why can two strings that display identically be unequal?
An accented letter can be stored as one combined character or as a base letter plus a combining mark. They render the same but hold different characters. normalize() rewrites both into one agreed form (NFC by default).
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
MDN — String.prototype.normalize()
Check there: normalize() returns the Unicode normalization of the string, defaults to NFC, and its examples show a composed character and a base letter plus combining mark comparing unequal until normalized.
MDN — String: UTF-16 characters, code points and grapheme clusters
Check there: The section of that name states that length is the count of UTF-16 code units, that characters outside the basic range take two units, and that iterating a string yields whole code points.
MDN — String.prototype.toLowerCase()
Check there: toLowerCase returns a new string and does not affect the original; toLocaleLowerCase exists for language-specific rules.
MDN — String.prototype.codePointAt()
Check there: codePointAt returns the whole code point at a position, unlike charCodeAt which returns a single UTF-16 unit.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown