Two identical-looking strings can fail ===, and Unicode is why
published
TL;DR
'é' === 'é' can be false. One of them is a single code point, U+00E9. The other is e followed by U+0301, a combining acute accent. They render identically and compare unequal.
Fix it at the boundary: run .normalize() on every string that enters your system from a file path, an upload, a paste, or a third-party API — before you compare it, hash it, or write it to a unique column.
The problem
You have a duplicate-detection check that says two records differ. You look at them. They are the same word.
const a = 'café'; // typed on Windows, pasted from a form
const b = 'café'; // read from a macOS filename
a === b; // false
a.length; // 4
b.length; // 5
The second string has five code units because the accent is a separate character sitting on top of the e. Every downstream operation inherits the mismatch:
SetandMapkeys don’t collide, so dedupe silently keeps both.- A
UNIQUEindex accepts both rows. You now have two users namedJosé. indexOf,includesandRegExpsearches miss — a user searchingcafégets nothing.- Hashes differ, so signature checks and content-addressed caches miss.
git statuson a macOS checkout can show a file as both deleted and untracked.
Nothing throws. That is what makes it expensive: the bug is a wrong answer, not an error.
Why it happens
Unicode allows more than one encoding for the same abstract character. UAX #15 defines four normalization forms, split along two axes — composed vs decomposed, and canonical vs compatibility.
| Form | What it does | 'é' becomes | 'fi' becomes | '²' becomes |
|---|---|---|---|---|
| NFC | Canonical decomposition, then recomposition | U+00E9 (1 cp) | U+FB01 (unchanged) | U+00B2 (unchanged) |
| NFD | Canonical decomposition | U+0065 U+0301 (2 cp) | U+FB01 (unchanged) | U+00B2 (unchanged) |
| NFKC | Compatibility decomposition, then recomposition | U+00E9 (1 cp) | fi (2 cp) | 2 |
| NFKD | Compatibility decomposition | U+0065 U+0301 (2 cp) | fi (2 cp) | 2 |
The canonical forms (NFC/NFD) round-trip: the characters are defined as equivalent, so converting between them loses nothing. The compatibility forms (NFKC/NFKD) do not — they collapse distinctions the standard considers formatting rather than identity, and ² really does become 2.
Where the mismatch comes from in practice:
- macOS file paths. The filesystem APIs hand back decomposed strings. A filename you wrote as NFC comes back as NFD.
- Copy-paste and IMEs. Different input methods emit different forms for the same keystroke.
- Third-party APIs. Whatever their storage layer did to the string, you inherit.
- The web platform leans NFC — that is what most browsers, most editors and most Linux/Windows tooling produce — but nothing enforces it.
What to do
Normalize on input, not on comparison. Comparing with normalization sprinkled at call sites means you will miss one.
// One boundary function, applied to everything that comes in.
const canon = (s) => s.normalize('NFC');
canon('café') === canon('café'); // true — regardless of which came from where
normalize() takes 'NFC', 'NFD', 'NFKC' or 'NFKD', and defaults to NFC when called with no argument (MDN). NFC is the right default for storage and comparison: it is the shortest form and the one the rest of the web already produces.
Where to put the call:
// Reading a directory on macOS: names may arrive decomposed.
import { readdir } from 'node:fs/promises';
const names = (await readdir(dir)).map((n) => n.normalize('NFC'));
// Before hashing or using as a key.
const key = input.trim().normalize('NFC');
cache.set(key, value);
Other layers have their own hooks:
- Postgres 13+ ships
normalize(text, form)and anIS NFC NORMALIZEDpredicate, so you can normalize in a generated column or assert the invariant in aCHECKconstraint (string functions). - Git has
core.precomposeUnicodefor the macOS case, which converts decomposed filenames back to precomposed before Git records them (git-config). - Passwords have a spec answer: RFC 8265’s
OpaqueStringprofile specifies NFC, so a password typed on one platform verifies on another (RFC 8265 §4.2).
To see which form a specific string is actually in, run it through the Unicode inspector — it splits any input into code points and shows all four normalizations side by side.
Caveats
- Normalization is not case folding.
'Café'.normalize('NFC') === 'café'.normalize('NFC')is stillfalse. Case is a separate step, andtoLowerCase()has its own locale traps (Turkish dotless ı being the classic one). - Normalization is not sorting. For user-facing ordering and locale-aware equality, use
Intl.Collator, not normalized===. - NFKC and NFKD are lossy. They are for search-and-match pipelines, not for storage. If you NFKC-normalize before saving, you have destroyed the difference between
x²andx2. - Byte length changes. In UTF-8,
éis 2 bytes as NFC and 3 as NFD. Normalizing on the way into a byte-limited column can change whether a value fits. - Emoji sequences are not affected. ZWJ sequences and skin-tone modifiers are already single canonical forms; normalization does not merge or split them.
- It does not fix homoglyphs. Cyrillic
а(U+0430) and Latina(U+0061) are different characters in every normalization form. That is a separate problem with a separate defence.