Accept the answer without the accents, then show the real spelling
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,COEURandcœurall matchcœ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.