Unicode name search in JavaScript without losing the spelling
Keep display strings intact, compare canonical forms, and return wider-search collisions as separate records.
Two strings can both display as Renée and still fail a strict equality check. The tempting repair is to remove every accent. That solves a different problem: Renée and Renee now share a key, and the original spelling may disappear if you save that key back into the profile.
Let's keep those decisions separate. We'll build a small JavaScript name index that preserves the submitted display string, uses canonical Unicode comparison by default, and makes broader matching an explicit option. Every search returns candidates with their existing record IDs.
Normalise the comparison
A precomposed é is one Unicode code point. The sequence e followed by a combining acute accent is another way to represent the same abstract character. Unicode calls that canonical equivalence.
In JavaScript, normalize('NFC') brings canonically equivalent strings into the same form. It preserves the accent in this example. Removing marks takes an additional step. That distinction matters: a matching rule is not permission to rewrite someone's name.
The complete example below makes both operations visible. Save the index as name-index.mjs:
const canonicalKey = value => value.normalize('NFC');
const markInsensitiveKey = value =>
value.normalize('NFD').replace(/\p{M}/gu, '');
function requireText(value, label) {
if (typeof value !== 'string' || value.trim() === '') {
throw new TypeError(`${label} must be a non-empty string`);
}
}
function addToBucket(index, key, record) {
const bucket = index.get(key) ?? [];
bucket.push(record);
index.set(key, bucket);
}
export function createNameIndex(input) {
if (!Array.isArray(input)) {
throw new TypeError('Expected an array of records');
}
const byId = new Map();
const byCanonical = new Map();
const byMarksRemoved = new Map();
for (const item of input) {
requireText(item?.id, 'id');
requireText(item?.displayName, 'displayName');
if (byId.has(item.id)) {
throw new Error(`Duplicate record ID: ${item.id}`);
}
const record = Object.freeze({
id: item.id,
displayName: item.displayName,
});
byId.set(record.id, record);
addToBucket(byCanonical, canonicalKey(record.displayName), record);
addToBucket(byMarksRemoved, markInsensitiveKey(record.displayName), record);
}
return Object.freeze({
find(query, mode = 'canonical') {
if (typeof query !== 'string') {
throw new TypeError('query must be a string');
}
if (mode !== 'canonical' && mode !== 'marks-removed') {
throw new RangeError('Unknown search mode');
}
if (query.trim() === '') return [];
const canonical = mode === 'canonical';
const key = canonical ? canonicalKey(query) : markInsensitiveKey(query);
if (key === '') return [];
const index = canonical ? byCanonical : byMarksRemoved;
return [...(index.get(key) ?? [])];
},
list() {
return [...byId.values()];
},
});
}
The original displayName never goes through either key function before it is stored. Each returned record is frozen, and the index copies the input fields into its own snapshot. Editing the original array later will not quietly change that snapshot. Result arrays are copies, so sorting or clearing one does not alter the index's buckets.
The two maps hold arrays of records, not one record per search key. Otherwise, inserting a second name with the same key could overwrite the first. A key collision belongs in the result, where the caller can see it.
Run three records through it
Save this beside the index as demo.mjs. The two spellings with an accent use explicit JavaScript escapes so you can see which representation each contains:
import { createNameIndex } from './name-index.mjs';
const composed = 'Ren\u00E9e';
const decomposed = 'Rene\u0301e';
console.log('Raw equality:', composed === decomposed);
console.log('NFC equality:', composed.normalize('NFC') === decomposed.normalize('NFC'));
// These IDs come from the application, not from a name transformation.
const records = [
{ id: 'record-101', displayName: composed },
{ id: 'record-202', displayName: 'Renee' },
{ id: 'record-303', displayName: decomposed },
];
const names = createNameIndex(records);
console.log('Canonical:', names.find(decomposed).map(record => record.id));
console.log('Marks removed:', names.find('Renee', 'marks-removed').map(record => record.id));
console.log('Original strings retained:', names.list().map(record => record.displayName));
The example was tested with Node.js 24.19.0. Run it with:
node demo.mjs
The important output is:
Raw equality: false
NFC equality: true
Canonical: [ 'record-101', 'record-303' ]
Marks removed: [ 'record-101', 'record-202', 'record-303' ]
The canonical search returns two records because both accented representations have the same NFC key. The unmarked spelling remains different. Choosing marks-removed returns all three.
Neither search combines records, picks a winner, or changes the display names. Even an exact canonical match can produce multiple candidates. The first result is not automatically the right one.
The IDs are supplied by the application. They are not generated from a normalised name. If a display name changes, retain that record's ID and rebuild this snapshot from the updated records. The example rejects duplicate IDs because quietly overwriting a record would hide a data error.
What the broader mode actually does
normalize('NFD') decomposes the text. /\p{M}/gu then removes Unicode characters in the Mark category. That is a deliberately lossy transformation. It is useful here to expose the collision, rather than to define the right search behaviour for every language.
It also does less than its appearance might suggest. This example stays case-sensitive, preserves spaces, and does not turn every non-English letter into an English one. For example, it will not match Łucja against Lucja. Pick any additional case, whitespace, or language rules explicitly and test them with appropriate examples.
Do not substitute NFKC on the assumption that it means “remove accents”. Compatibility normalisation has a different purpose and can remove other distinctions. MDN's explanation shows why a transformed value suitable for search may be unsuitable for display.
The default here remains canonical comparison. Blank queries return no results, unknown modes throw, and non-string input is rejected rather than becoming an accidental search for [object Object]. These choices make unintended broad matching harder to introduce through a typo or an empty search box.
Keep a collision in the test suite
A test that checks only the composed and decomposed strings misses the main product risk. Include an unmarked spelling and assert that broad search returns every distinct record.
Save this as collision.test.mjs:
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNameIndex } from './name-index.mjs';
test('broad search preserves collisions and display spelling', () => {
const input = [
{ id: 'a', displayName: 'Ren\u00E9e' },
{ id: 'b', displayName: 'Renee' },
{ id: 'c', displayName: 'Rene\u0301e' },
];
const names = createNameIndex(input);
assert.deepEqual(names.find('Renee', 'marks-removed'), input);
assert.deepEqual(names.find('Rene\u0301e').map(record => record.id), ['a', 'c']);
assert.deepEqual(JSON.parse(JSON.stringify(names.list())), input);
});
Run node --test collision.test.mjs. This checks both the wider result set and a JSON export/import round trip. The stored strings survive that round trip exactly, including their different internal representations.
Before using the pattern in an application, follow the display name into exports, messages, and reimported records too. Keeping it intact inside this index will not help if another layer replaces it with the search key.
A name result is a set of text matches. Account access and decisions about a person's identity need their own authorised controls. When you show results in a UI, render the names as text rather than treating them as HTML. This index also does not decide how to rank results, handle every language, or coordinate database updates.
Keep the value you display separate from the values you compare. Let search be forgiving where you have chosen that behaviour, while keeping the original spelling and each record's existing ID intact.
Keith Staggers is an AI trainer and automation builder in Tampa, Florida. He creates practical training materials and tools at Keith Staggers Studio.
