~/blog

Two identical-looking strings can fail ===, and Unicode is why

published

#unicode#javascript#encoding

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:

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.

FormWhat it does'é' becomes'fi' becomes'²' becomes
NFCCanonical decomposition, then recompositionU+00E9 (1 cp)U+FB01 (unchanged)U+00B2 (unchanged)
NFDCanonical decompositionU+0065 U+0301 (2 cp)U+FB01 (unchanged)U+00B2 (unchanged)
NFKCCompatibility decomposition, then recompositionU+00E9 (1 cp)fi (2 cp)2
NFKDCompatibility decompositionU+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:

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:

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

References