Accept the answer without the accents, then show the real spelling

  • typescript
  • unicode
  • ux

I have a side project that quizzes people on vocabulary in a language with a lot of accents. Some cards are multiple choice, but the ones that actually build memory make you type the word. The first version of the answer checker was strict in a way that felt principled: if the word has an accent and you left it off, you get a note, and on a handful of cards marked “strict” you get it wrong.

It took very little real use to see that this was grading the wrong thing. Most people type these answers on a phone or a US keyboard. Getting ô means a long press or a dead key, and plenty of people never bother. When someone types aussitot for aussitôt, they remembered the word. What they did not do is fight their keyboard. Marking that as a miss sends a word they know back to the start of the review queue, and repeated “wrong” answers on words you know is the fastest way to make someone quit a study app.

So I changed the rule: accents are optional on every typed card, strict ones included. An answer that matches after folding counts as fully correct, and the feedback quietly shows the canonical spelling. Here is how to do that without getting bitten by Unicode.

Fold both sides, not just the input

The core idea is a fold() function that maps a string to a comparison key, and you apply it to both the typed answer and every accepted answer from your data. Folding only the input is a common mistake: it works until your content has an accent and the user’s input doesn’t, which is the entire point.

const LIGATURES: Record<string, string> = { œ: 'oe', Œ: 'oe', æ: 'ae', Æ: 'ae' };

export function normalize(s: string): string {
	return s
		.trim()
		.toLowerCase()
		.replace(/[\u2018\u2019\u02bc]/g, "'") // curly and modifier apostrophes
		.replace(/[.,!?;:"]/g, '')
		.replace(/\s+/g, ' ');
}

export function fold(s: string): string {
	return normalize(s)
		.replace(/[œŒæÆ]/g, (c) => LIGATURES[c])
		.normalize('NFD')
		.replace(/\p{M}/gu, '');
}

Three details in there are worth calling out.

NFD first, then strip marks. normalize('NFD') splits a precomposed character like ô into o plus a combining circumflex. The \p{M} class then removes every combining mark. This also quietly handles a problem you may not know you have: the same visible word can arrive in two encodings. Some keyboards and some copy and paste sources send decomposed text, and a plain === between composed où and decomposed où is false even though they look identical. Decomposing both sides makes them equal.

Ligatures are not accents. œ is its own letter in Unicode, not o plus a mark, so NFD leaves it alone. Nobody types cœur on a phone; they type coeur. You need an explicit mapping, and it has to cover upper case too, since you may fold before or after lowercasing depending on where the function is reused.

Keep your existing normalization. Trimming, lowercasing, unifying apostrophes and stripping punctuation were already in place. Folding goes on top of that, not instead of it. The curly apostrophe one matters more than it looks: phones love to autocorrect ' into ’.

Return which answer matched, not just a boolean

A checker that returns true or false cannot tell the interface what to show. I want three outcomes: an exact match, a match only after folding, and a miss. And for the folded case, I want the spelling from the data, not a reconstruction from what the user typed.

type Verdict = 'exact' | 'folded' | 'wrong';

export function matchAnswer(
	typed: string,
	accepted: string[],
): { verdict: Verdict; canonical: string | null } {
	const n = normalize(typed);
	if (!n) return { verdict: 'wrong', canonical: null };

	const exact = accepted.find((a) => normalize(a).normalize('NFC') === n.normalize('NFC'));
	if (exact) return { verdict: 'exact', canonical: exact };

	const f = fold(typed);
	const folded = accepted.find((a) => fold(a) === f);
	if (folded) return { verdict: 'folded', canonical: folded };

	return { verdict: 'wrong', canonical: null };
}

Checking exact first matters when a card has several accepted answers that fold to the same key. If someone typed one of them perfectly, they should see the plain “Right.” with no spelling note, not a correction that points at a different variant. Comparing in NFC on the exact pass keeps a decomposed but otherwise perfect answer from being treated as a near miss.

The empty check is there because fold('') equals fold(''), and an accepted answer made of only punctuation would otherwise match a blank submission. Cheap to guard, embarrassing to ship.

Score it as correct, say it quietly

The scheduling side is where the policy actually lives. A folded match moves the card up a box, counts toward the session score and toward unlocking the next step, exactly like an exact match. The only difference is the feedback line:

const headline =
	result.verdict === 'exact'
		? 'Right.'
		: result.verdict === 'folded'
			? `Correct. Spelled: ${result.canonical}.`
			: 'Not quite.';

The wording took a couple of tries. My first version said “Right. Watch the accents”, which reads as a scolding even though the answer counts. “Correct. Spelled: où.” states a fact and moves on. Someone who cares about spelling will notice it. Someone who does not is not punished for it.

The tradeoff you are accepting

There are real pairs where the accent is the whole difference. ou means “or” and où means “where”. That is exactly why those cards were strict in the first place. Accepting ou for où means the checker can no longer tell whether someone knows the difference.

I decided that was fine for this kind of card, for two reasons. The typed card is testing recall of a word, and the prompt already says which meaning it wants, so the person typing ou there almost certainly meant où. And the canonical spelling is shown every single time, so the correct form keeps getting reinforced. If I ever want to test the distinction itself, that is a different card: a multiple choice between the two, where the keyboard is not involved at all.

That is the general lesson I took from this. Decide what a question is actually measuring, and make sure the grader is not measuring something else, like typing skill or keyboard layout, by accident.

Tests that earn their keep

The tests that caught real bugs here were not the obvious ones:

  • A strict card typed without accents is correct and returns the canonical spelling.
  • Composed and decomposed input for the same word both count as exact.
  • coeur, COEUR and cœur all match cœur, and only the last is exact.
  • A genuinely wrong answer is still wrong after folding.
  • A whole session answered without a single accent reaches the unlock with no misses, which proves the scheduler and the checker agree on what “correct” means.

That last one is the important one. It is easy to update the checker and forget a second place, like a session summary or a re-show queue, that still has its own idea of a miss.

The short version

  • Fold both sides: NFD, strip combining marks, map ligatures explicitly, keep your existing punctuation and apostrophe handling.
  • Return the verdict and the matched accepted answer, and check for an exact match first.
  • Count folded matches as correct everywhere scoring happens, and show the real spelling as a quiet note.
  • If a distinction really matters, test it with a card built for it, not by being strict about the keyboard.