Flutter gen-l10n Apostrophes and Braces: use-escaping Fix
You ship a French build and a screen reads Lutilisateur {name} nexiste pas. The apostrophes are gone and the placeholder is printed raw. Or the build stops with ICU Lexing Error: Unmatched single quotes. Or you tried to put a literal {} in a string and got ICU Syntax Error: Expected "identifier" but found "}".
All three come from one rule: how flutter gen-l10n treats the single quote. Here is what the parser does, checked against the Flutter tool source and a real project on Flutter 3.38, and how to fix each case.
Three symptoms, one cause
gen-l10n has two modes, controlled by use-escaping in l10n.yaml. It is off by default.
With use-escaping off (the default), a single quote is an ordinary character. This French string works as is:
{
"userGreeting": "L'utilisateur {name}"
}
The generated Dart is 'L\'utilisateur $name'. Nothing to fix. What does not work in this mode is a literal brace, because every { opens a placeholder:
{
"emptyObject": "Tapez {} pour un objet vide"
}
[app_fr.arb:emptyObject] ICU Syntax Error: Expected "identifier" but found "}".
Tapez {} pour un objet vide
^
Found syntax errors.
With use-escaping: true, the single quote becomes syntax. Anything between a pair of single quotes is copied through as plain text, and two quotes in a row ('') produce one real apostrophe. This is the flag most teams turn on the day they need a literal brace, and it is the moment French and Italian strings start breaking:
| ARB value | Result with use-escaping: true |
|---|---|
L'utilisateur {name} |
Build fails: ICU Lexing Error: Unmatched single quotes. |
L'utilisateur {name} n'existe pas |
Builds, prints Lutilisateur {name} nexiste pas |
L''utilisateur {name} n''existe pas |
L'utilisateur Marie n'existe pas |
The second row is the dangerous one. An even number of apostrophes pairs up, so the parser treats utilisateur {name} n as a quoted section. The quotes are dropped, the placeholder is never substituted, and gen-l10n reports no error. French (l', d', n', qu') and Italian (l', dell', un') hit this constantly. English mostly escapes it because a string rarely has two apostrophes.
The ICU quoting rule behind it
ARB messages use ICU MessageFormat syntax, where { and } are reserved and the apostrophe is the escape character. Flutter's parser applies a strict version of that rule when escaping is on. In the tool source, a quoted section is matched with the pattern '[^']*': a quote, anything that is not a quote, then a closing quote. An empty match ('') becomes a literal apostrophe. A quote with no partner throws the "Unmatched single quotes" error.
Full ICU implementations are more forgiving by default: a lone apostrophe only starts a quoted section when it sits directly before a brace. gen-l10n does not do that. With use-escaping: true, every single quote counts.
The fix: use-escaping plus doubled quotes
Turn the flag on in l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
use-escaping: true
Then follow three rules in every app_*.arb file.
1. Double every real apostrophe
{
"userMissing": "L''utilisateur {name} n''existe pas",
"cartEmpty": "{count, plural, =0{Pas d''article} other{{count} articles}}"
}
This applies inside plural and select branches too.
2. Quote literal braces
Wrap the brace, or the whole braced chunk, in single quotes:
{
"templateHint": "Utilisez '{name}' ou '{}' dans le modèle",
"helloWorld": "Hello! '{Isn''t}' this a wonderful day?"
}
These generate Utilisez {name} ou {} dans le modèle and Hello! {Isn't} this a wonderful day?. The second example is the one from the Flutter documentation, and it shows that '' still means an apostrophe inside a quoted section.
3. Or use the typographic apostrophe
The parser only reacts to the straight quote ' (U+0027). The curly apostrophe ’ (U+2019) is plain text in both modes:
{
"userGreeting": "L’utilisateur {name}"
}
This is also the correct character in French and Italian typography, so many teams standardise on it for translations and keep ' only for escaping. The trade-off is consistency: make sure translators and your font both handle it, and do not mix the two styles in one locale.
What changes when you flip the flag on an existing app
The flag is global. It applies to every message in every locale, including the template. Before you merge the change:
- Every straight apostrophe in every ARB file must become
''(or’). Strings with one apostrophe will fail the build, which is the easy case. - Strings with two or four apostrophes will build and ship wrong. These are the ones to hunt down.
- Strings that were already written with
''(some translation tools export ICU style quoting) will go from showing two apostrophes to showing one, which is usually the bug you wanted fixed. - English is affected too:
Don'tbecomesDon''t.
A plain search and replace of ' with '' across the ARB files is safe only if you have no quoted braces yet. Do it in the same commit that adds the flag.
If you only need literal braces: relax-syntax
Recent Flutter versions also have a relax-syntax option. It leaves apostrophes alone and treats { as text when it is not followed by a known placeholder name, and } as text when it closes nothing:
relax-syntax: true
With it, Tapez {} pour un objet vide and Exemple: {"id": 1} both generate cleanly, with no quoting at all. It cannot print a literal {name} when name is a real placeholder of that message. For that you still need use-escaping.
A CI check for lone apostrophes
gen-l10n catches the odd apostrophe but not the paired ones. This script catches both. Save it as tool/check_arb_quotes.dart:
// Run with: dart run tool/check_arb_quotes.dart [l10n dir]
import 'dart:convert';
import 'dart:io';
final _quoted = RegExp(r"'([^']*)'");
List<String> problemsIn(String message) {
// '' is always a literal apostrophe, so it can never be a problem.
final rest = message.replaceAll("''", '');
final problems = <String>[];
if ("'".allMatches(rest).length.isOdd) {
problems.add('lone apostrophe (gen-l10n: "Unmatched single quotes")');
}
for (final match in _quoted.allMatches(rest)) {
final inner = match.group(1)!;
if (!inner.contains('{') && !inner.contains('}')) {
problems.add("quotes around '$inner' escape nothing, "
'both apostrophes will be dropped');
} else if (RegExp(r'[a-zA-Z]\s|\s[a-zA-Z]').hasMatch(inner)) {
problems.add("quoted section '$inner' spans several words, "
'placeholders inside it will print raw');
}
}
return problems;
}
void main(List<String> args) {
final dir = Directory(args.isEmpty ? 'lib/l10n' : args.first);
final files = dir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith('.arb'))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
var failures = 0;
for (final file in files) {
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
for (final entry in arb.entries) {
final value = entry.value;
if (entry.key.startsWith('@') || value is! String) continue;
for (final problem in problemsIn(value)) {
failures++;
stderr.writeln('${file.path}: ${entry.key}: $problem\n $value');
}
}
}
if (failures > 0) {
stderr.writeln('\n$failures apostrophe problem(s). '
"Use '' for a real apostrophe, or the typographic ’.");
exit(1);
}
stdout.writeln('ARB quotes OK (${files.length} files).');
}
It flags three things: an unmatched quote, a quoted section that contains no brace (so the quotes are really apostrophes), and a quoted section that spans several words around a placeholder. Run on the broken file from earlier it prints:
lib/l10n/app_fr.arb: userGreeting: lone apostrophe (gen-l10n: "Unmatched single quotes")
L'utilisateur {name}
lib/l10n/app_fr.arb: userMissing: quoted section 'utilisateur {name} n' spans several words, placeholders inside it will print raw
L'utilisateur {name} n'existe pas
It is a heuristic, so a deliberately quoted sentence with braces in it will be reported. That is rare enough to rewrite. Wire it in before code generation, for example in GitHub Actions:
- run: dart run tool/check_arb_quotes.dart lib/l10n
- run: flutter gen-l10n
flutter gen-l10n exits with a non zero code on syntax errors, so the second step covers anything the parser itself rejects.
Keep translations readable while you fix them
Doubled quotes are easy to miss in raw JSON, especially in a file with a few hundred keys. The FlutterLocalisation ARB editor lets you edit app_<locale>.arb translations in a UI and manage many locales side by side, and it validates ICU plural syntax so a locale missing a plural category it needs gets flagged. Pair it with the script above and the apostrophe class of bugs stops reaching users.
For more on plurals, placeholders and ARB structure, see the other tutorials on the FlutterLocalisation blog, and check pricing for what the free tier includes.
Try FlutterLocalisation free
Stop hand editing JSON for every French and Italian string. Try FlutterLocalisation free and manage your ARB files in one place.