Unicode Lookalikes Developers Should Know: When Identical-Looking Characters Aren't the Same
Two characters can look pixel-for-pixel identical on your screen and still be completely different pieces of data.
Obvious, right? If you work with Unicode every day, sure. But in practice it's shockingly easy to copy a character from a doc, a website, a Slack message, a spreadsheet, an AI response — and just assume you know what you grabbed.
Take these two:
V
Ⅴ
Depending on your font, you might not see any difference at all. I've stared at pairs like this for a solid minute before giving up and checking the code points.
The first one is:
V U+0056 LATIN CAPITAL LETTER V
And the second:
Ⅴ U+2164 ROMAN NUMERAL FIVE
Different characters. Different code points. Same face.
And that little difference ripples into string comparison, search, identifiers, parsing, source code — even security. Here are the lookalikes worth knowing about, and what you can actually do about them.
1. V and Ⅴ: a letter versus a Roman numeral
This one's the cleanest example, so let's start here.
V U+0056 LATIN CAPITAL LETTER V
Ⅴ U+2164 ROMAN NUMERAL FIVE
Yes, Unicode has dedicated Roman numeral characters. A whole set of them:
Ⅰ U+2160
Ⅱ U+2161
Ⅲ U+2162
Ⅳ U+2163
Ⅴ U+2164
Ⅹ U+2169
Here's the funny part though — for everyday Roman numerals, Unicode itself says: just use regular Latin letters. So:
Chapter IV
beats:
Chapter Ⅳ
in almost every situation. The dedicated characters mostly exist for compatibility with East Asian standards. They weren't really meant for your book chapters.
Why does any of this matter in code? Because:
const latinV = "V";
const romanFive = "Ⅴ";
console.log(latinV === romanFive);
// false
Search for V and you won't automatically match Ⅴ either. Separate code points, separate results.
Now, here's where it gets interesting. Roman numeral five has a Unicode compatibility mapping back to the plain letter V. JavaScript shows this off nicely:
console.log("Ⅴ".normalize("NFKC"));
// "V"
console.log("Ⅴ".normalize("NFKC") === "V");
// true
That does not mean the originals were the same character. It means compatibility normalization deliberately flattens the distinction. Big difference — and it'll come up again.
2. µ and μ: the micro sign and Greek mu
Another pair that's nearly impossible to tell apart by eye:
µ U+00B5 MICRO SIGN
μ U+03BC GREEK SMALL LETTER MU
The first is the micro sign — the one you see in µs and µm. The second is the actual lowercase Greek letter mu.
Separate characters:
console.log("µ" === "μ");
// false
But Unicode defines the micro sign as compatibility-equivalent to Greek mu, so NFKC folds it:
console.log("µ".normalize("NFKC"));
// "μ"
console.log(
"µ".normalize("NFKC") ===
"μ".normalize("NFKC")
);
// true
This pair is a great teaching example, honestly, because it forces you to keep four different ideas straight:
character equality;
visual similarity;
canonical equivalence;
compatibility equivalence.
Four concepts. Not one. Mixing them up is where most Unicode bugs are born.
3. φ and ϕ: two phi characters
Unicode ships with two lowercase phis. Because of course it does.
φ U+03C6 GREEK SMALL LETTER PHI
ϕ U+03D5 GREEK PHI SYMBOL
Separate code points:
console.log("φ" === "ϕ");
// false
Why two? The alternate phi shape earns its keep as a technical and mathematical symbol. Unicode treats φ as the ordinary Greek letter, while ϕ is the math/technical variant.
There's a wrinkle here — exact appearance depends on the font. Unicode actually swapped the representative glyphs for these characters early in its history, and fonts don't always draw the two forms the way you'd expect. Fun times for anyone typesetting physics papers.
From a data perspective, none of that matters. The code point is the code point.
And like the micro sign, the phi symbol carries a compatibility mapping to the regular phi:
console.log("ϕ".normalize("NFKC"));
// "φ"
So once more:
console.log("φ" === "ϕ");
// false
console.log(
"φ".normalize("NFKC") ===
"ϕ".normalize("NFKC")
);
// true
4. | and ¦: pipe versus broken bar
These two are easier to distinguish in most fonts:
| U+007C VERTICAL LINE
¦ U+00A6 BROKEN BAR
Most fonts. Not all.
Old keyboard layouts and fonts muddied the waters here for years — some keycaps literally printed a broken-looking bar on the key that types U+007C. So the confusion isn't your fault. It's historical baggage.
Why should you care? Because shells and programming languages want the real vertical line wherever | carries meaning:
cat file.txt | grep "error"
That's:
| U+007C
Swap in:
¦ U+00A6
and you don't have a pipe anymore. You have a syntax error waiting to happen. Same story everywhere | means something — regex alternation, language operators, Markdown tables, TypeScript union types, you name it.
And here's the kicker: NFKC does not rescue you this time.
console.log("¦".normalize("NFKC"));
// "¦"
console.log(
"¦".normalize("NFKC") === "|"
);
// false
Which teaches an important lesson:
NFKC is not a universal "fix weird Unicode" function.
I've seen people treat it like one. It isn't.
5. -, ‐, –, and — are four different characters
Dashes. Everyone's favorite.
- U+002D HYPHEN-MINUS
‐ U+2010 HYPHEN
– U+2013 EN DASH
— U+2014 EM DASH
Four characters, four jobs.
U+002D is the ASCII hyphen-minus — the one your keyboard makes and the one programming lives on. U+2010 is the dedicated Unicode hyphen almost nobody types on purpose. U+2013 is the en dash for ranges:
2025–2026
And U+2014 is the em dash for breaks in prose:
The deployment succeeded — eventually.
Now, you might hope normalization collapses all of these into ASCII -. It doesn't:
console.log("–".normalize("NFKC") === "-");
// false
console.log("—".normalize("NFKC") === "-");
// false
Want to treat multiple dash characters as equivalent somewhere in your app? Fine — but that's a decision you have to make and implement explicitly. Unicode won't make it for you.
6. The more serious case: Latin and Cyrillic lookalikes
Everything so far has been compatibility-character territory. Annoying, but mostly harmless.
This one isn't.
Unicode contains perfectly legitimate characters from different writing systems that happen to look nearly identical:
a U+0061 LATIN SMALL LETTER A
а U+0430 CYRILLIC SMALL LETTER A
Look closely at those. Go ahead. In most fonts you genuinely cannot tell them apart.
But:
console.log("a" === "а");
// false
And NFKC leaves them alone:
console.log(
"a".normalize("NFKC") ===
"а".normalize("NFKC")
);
// false
That's intentional — and correct. Cyrillic а isn't some decorative flourish on Latin a. It's a real letter in a real alphabet used by hundreds of millions of people. Merging them would be wrong.
The trouble is what bad actors do with this. Unicode's own security spec uses a now-famous example:
paypal
pаypаl
In the second string, one or more of those "a"s can be Cyrillic. Your eyes say the strings match. Your equality check says otherwise — and an attacker registering the spoofed domain is counting on exactly that gap.
Unicode calls strings like these confusables. And they're the reason "it looks right" is never good enough for security-sensitive identifiers.
Normalization helps — but it is not confusable detection
JavaScript gives you four normalization forms:
"NFC"
"NFD"
"NFKC"
"NFKD"
The first two handle canonical equivalence. The last two layer compatibility equivalence on top.
So these work:
"Ⅴ".normalize("NFKC") // "V"
"µ".normalize("NFKC") // "μ"
"ϕ".normalize("NFKC") // "φ"
But these don't budge:
"а".normalize("NFKC") // still Cyrillic а
"¦".normalize("NFKC") // still broken bar
"—".normalize("NFKC") // still em dash
Which brings us to the rule worth taping to your monitor:
Normalization and confusable detection solve different problems.
For the confusable problem specifically, Unicode Technical Standard #39 defines a dedicated mechanism. It derives a comparison form called a confusable skeleton, built from Unicode's confusables data — not from wishful thinking about what "should" normalize together.
Handling usernames? Package names? Identifiers where visual spoofing could hurt someone? Then NFKC alone isn't your security layer. Don't pretend it is.
How to find out what character you actually have
When text starts acting weird, don't trust the glyph. Trust the code point.
Here's a tiny JavaScript helper I keep reaching for:
function inspectCharacters(text) {
return [...text].map((char) => ({
character: char,
codePoint:
"U+" +
char
.codePointAt(0)
.toString(16)
.toUpperCase()
.padStart(4, "0"),
}));
}
console.table(inspectCharacters("VⅤµμaа"));
Run it and you'll see:
V U+0056
Ⅴ U+2164
µ U+00B5
μ U+03BC
a U+0061
а U+0430
Ten lines of code. And it can explain bugs that are basically invisible any other way.
A practical defensive strategy
Bad news first: there's no single transformation that makes every Unicode problem disappear. Anyone selling you one is selling you a bug.
The realistic approach? Handle Unicode based on what the string represents.
For ordinary human-readable text
Preserve what the author actually wrote. Canonical normalization like NFC makes sense when you need equivalent sequences stored consistently — and usually that's enough.
For controlled identifiers
Decide, explicitly, which characters and scripts you allow. And never assume something that looks like ASCII actually is ASCII. (See: everything above.)
For compatibility-sensitive matching
NFKC earns its place here — it folds a lot of compatibility characters into their plain equivalents. But remember it's destructive by design. It erases distinctions on purpose. Choose it because your requirements call for it, not as a reflex applied to every string you store.
For security-sensitive identifiers
Reach for real confusable-detection guidance — the mechanisms in Unicode Technical Standard #39. Normalization alone won't save you.
When debugging
Print the code points.
Always.
A character's appearance is a rendering decision. Its code point tells you what data you're actually holding.
The character is part of your data model
Unicode is a genuine marvel — it lets software represent nearly every human writing system, plus math notation, symbols, historical scripts, technical characters, the works.
The price of that power? The link between what a character looks like and what character it actually is gets fuzzy. Sometimes very fuzzy.
So the next time two strings mysteriously fail an equality check, or search misses something that's clearly right there, or copied code that looks perfectly fine refuses to run — inspect the characters first. Before you tear apart everything around them.
Sometimes the bug isn't in your algorithm at all.
It's hiding in plain sight, wearing a different code point.
References
Technical details in this article were checked against:
The Unicode Standard, Version 17.0
Unicode Standard Annex #15 — Unicode Normalization Forms
Unicode Technical Standard #39 — Unicode Security Mechanisms
Unicode Character Code Charts and Names Lists
ECMAScript specification for
String.prototype.normalize()
By Igor R., who spends more time than he'd like to admit writing about Unicode.
