Romanian diacritics exist twice in Unicode, and your regex only knows one

A validation rule started rejecting company names. Real ones, correctly spelled, typed by people who knew how to spell them. The rule had been in production for months and had been fine.

The rule enumerated the Romanian alphabet. That was the bug, and it is a bug you cannot see by reading the code, because the characters look right on screen.

Two spellings, one letter

Romanian uses s with comma below and t with comma below. Unicode has them:

  • ș U+0219 LATIN SMALL LETTER S WITH COMMA BELOW
  • ț U+021B LATIN SMALL LETTER T WITH COMMA BELOW

It also has, from earlier standards, s with cedilla and t with cedilla, which are Turkish letters that were used for Romanian for years because they were what was available:

  • ş U+015F LATIN SMALL LETTER S WITH CEDILLA
  • ţ U+0163 LATIN SMALL LETTER T WITH CEDILLA

The comma-below forms are correct for Romanian. They were added to Unicode later, Windows shipped the cedilla versions in its Romanian layouts for a long time, and older documents, older databases and older exports are full of them.

In many fonts the two are drawn nearly identically. In some fonts they are drawn identically, because the font substitutes. So the difference is invisible to a reader and absolute to a computer: "Construcții" !== "Construcţii", and no amount of staring at the two strings will tell you which is which.

Where this actually bites

Character classes you wrote by hand. /^[a-zA-Z0-9ăâîșțĂÂÎȘȚ \-]+$/ looks thorough and rejects half your real data, because it contains the comma-below forms and the incoming string has cedillas. Or the reverse. Whoever wrote the regex typed it on one machine with one keyboard layout.

Deduplication. The same company arrives twice under what a human reads as one name, and your unique constraint permits both.

Search. A user types the name with the keyboard they have. Your data holds the other spelling. Zero results, and the user concludes the record is missing.

Sorting. The two code points sort to different places, so a list of clients has the Ș names in two separate clusters.

URLs and slugs. Only if your slug generator handles one form. The other passes through into a slug, or gets stripped entirely, leaving constructii and constructi as two different pages.

What not to do

Do not write a mapping table of the eight characters. It works and it is the beginning of a long tradition of adding to it. Somebody will paste text with a combining comma as a separate code point, or a non-breaking hyphen, or a Cyrillic а that looks exactly like a Latin one, and each of those becomes another line.

Do not "just strip non-ASCII". That turns Ștefan into tefan and Ioniță into Ioni, and now the data is wrong in a way that is much harder to notice than a rejection.

What to do instead

Normalise, then fold by code point range.

Unicode normalisation decomposes a letter with a mark into the base letter plus a combining mark. Once decomposed, you remove the combining marks by their range rather than by naming letters:

const fold = (s) =>
  s.normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")   // combining marks, not a letter list
    .toLowerCase();

fold("Construcții") === fold("Construcţii");   // true

The important part is that range, \u0300-\u036f. It is a property of the encoding rather than a list of the letters you happened to think of, so it handles Romanian, Polish, Turkish, French and everything else without ever being edited again.

Two caveats worth knowing. NFD does not decompose everything: some characters have no decomposition, and the cedilla forms decompose to a base plus a cedilla mark rather than a comma-below mark, which is why folding both to plain s and t works and comparing "which mark is it" does not. And German ß does not become ss under any normalisation, so if you ever need that, it is a separate rule.

Store what the user typed. Compare on the folded form. Keep a name column with the real spelling, diacritics and all, and a name_normalised column with the folded version, indexed. Search and uniqueness use the second. Display uses the first. Never overwrite the first with the second, which is the shortcut that destroys data permanently and is always suggested by somebody at some point.

Validate by what you forbid, not by what you allow. Instead of a whitelist of letters, reject control characters and the handful of things you actually care about. A company name field does not need to know the alphabet. It needs to not contain a newline.

The wider point

This is the same class of problem as the fields that fail e-Factura validation: data that was perfectly adequate until something started comparing it exactly.

Almost every "unicode bug" in a Romanian system is this shape: a rule written by enumerating the things the author could see, meeting data produced by a decade of different keyboards, operating systems and exports.

The general fix is not a longer list. It is finding the property that makes the list unnecessary. This one costs three lines and never needs revisiting, which is the only kind of internationalisation code worth having.