Fix Flutter gen-l10n Placeholder Mismatch Between Locales
You send app_en.arb to an agency. Two weeks later app_fr.arb and app_ar.arb come back, you drop them into lib/l10n/, run flutter run — and one of three things happens. The build explodes inside generated code you never wrote. Or a call site that compiled yesterday suddenly wants an extra argument. Or, most often on a current SDK, everything builds clean and French users see "Articles dans votre panier" with no number in it.
All three have the same root cause: a translator dropped or renamed a {count}. Here is what gen-l10n actually does with that, why the error message points at the wrong file, and how to catch it before CI.
One ARB file can change every locale's method signature
gen-l10n does not generate each locale in isolation. Inside flutter_tools, generateMethodParameters() builds the parameter list from a single shared map per message:
// packages/flutter_tools/lib/src/localizations/gen_l10n.dart
return message.templatePlaceholders.entries.map((MapEntry<String, Placeholder> e) {
final Placeholder placeholder = e.value;
// ...
return '${useNamedParameters ? 'required ' : ''}${placeholder.type} ${placeholder.name}';
}).toList();
That one list is emitted into the abstract AppLocalizations base class and into every AppLocalizationsFr / AppLocalizationsAr subclass. Two consequences fall out of it, and most write-ups miss both.
Failure mode 1: the dropped placeholder that builds clean
Here is the template and the French file exactly as an agency returned it.
// lib/l10n/app_en.arb (template)
{
"@@locale": "en",
"welcomeBack": "Welcome back, {name}!",
"@welcomeBack": {
"description": "Greeting on the home screen",
"placeholders": { "name": { "type": "String", "example": "Amina" } }
},
"cartItems": "{count, plural, =0{Your cart is empty} =1{1 item in your cart} other{{count} items in your cart}}",
"@cartItems": {
"description": "Cart line count",
"placeholders": { "count": { "type": "num" } }
}
}
// lib/l10n/app_fr.arb (BROKEN — placeholders gone)
{
"@@locale": "fr",
"welcomeBack": "Bon retour !",
"cartItems": "Articles dans votre panier"
}
Run flutter gen-l10n and it succeeds. Open the generated app_localizations_fr.dart and you see why:
class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String welcomeBack(String name) {
return 'Bon retour !'; // `name` is accepted and thrown away
}
@override
String cartItems(num count) {
return 'Articles dans votre panier'; // plural collapsed to one form
}
}
The signature is correct because it came from the template. The body is wrong because it came from the translation. Dart has nothing to complain about — an unused parameter is legal — so this ships. The flutter gen-l10n summary of untranslated messages won't help either: welcomeBack is translated. A count of missing keys says nothing about a key that exists and lost its interpolation.
This is the single most common form of flutter localization placeholder mismatch between locales, and it is invisible to the compiler.
Failure mode 2: the typo that breaks every call site
Now the Arabic file, where a French string leaked in during handoff and {name} became {nom}:
// lib/l10n/app_ar.arb (BROKEN — undeclared placeholder)
{
"@@locale": "ar",
"welcomeBack": "مرحبًا بعودتك يا {nom}!",
"cartItems": "{count, plural, other{{count} عنصر في سلتك}}"
}
gen-l10n does not reject nom. Its _inferPlaceholders() pass walks the parsed AST of every locale, and any identifier it can't resolve is created on the spot and merged back into the shared template map:
if (placeholder == null) {
placeholder = Placeholder(resourceId, identifier, <String, Object?>{});
undeclaredPlaceholders[identifier] = placeholder;
}
// ...later, after all locales are walked:
templatePlaceholders.addEntries(undeclaredPlaceholders.entries.toList()..sort(...));
With no "type" attribute to read, the placeholder falls back to placeholder.type ??= 'Object'. Your base class silently grows a parameter:
// abstract class AppLocalizations
String welcomeBack(String name, Object nom);
And every existing call site fails:
lib/screens/home.dart:42:34: Error: Too few positional arguments: 2 required, 1 given.
Text(AppLocalizations.of(context)!.welcomeBack(user.firstName)),
^
The analyzer words it as 2 positional argument(s) expected, but 1 found. Either way, nothing in that output mentions app_ar.arb. You go hunting through home.dart and the generated file, when the actual defect is one Arabic string. Because undeclared placeholders are appended in sorted order, a second typo elsewhere can also reorder positional parameters — which is the argument for turning on use-named-parameters in your l10n.yaml so the analyzer names what's missing instead of counting slots.
"gen_l10n isn't a valid override" — the 3.7 regression
If you're on a pinned older SDK (or searching an old Stack Overflow answer), you may hit the harder version of this:
Error: Can't declare a member that conflicts with an inherited one.
String get devOptionsEnabled => '';
^^^^^^^^^^^^^^^^^
String devOptionsEnabled(String appName);
^^^^^^^^^^^^^^^^^
That's flutter#119557, a P1 regression in Flutter 3.7. Back then the generator decided getter-vs-method from each locale's own string, so a French file without {appName} produced a no-arg getter that couldn't override the base class method. The cherry-pick request described it precisely: ARB files whose fields don't use the placeholder syntax the template does get "generated methods [that] won't have the same signature as the template, which will cause invalid overrides that makes the project unable to run."
PR #120129 fixed it by keying the decision off templatePlaceholders, and it appears in the Flutter 3.10 release notes. Upgrading fixes the crash — but it converts failure mode 1 from a red build into a silent one. That trade is why you now need your own check.
The plural variant that hides until runtime
Look again at the Arabic cartItems. It has a valid ICU plural and it will compile, because gen-l10n only enforces that an other case exists (ICU Syntax Error: Plural expressions must have an "other" case.). It does not know that Arabic's CLDR rules need zero, one, two, few, many, and other. Ship it and every Arabic user sees the other wording for 2 items, for 3 items, for 11 items — grammatically wrong, and no test catches it unless you assert on translated output.
The reverse trips the build instead: if one locale uses {count} as a plural argument while the template treats it as plain text, type inference collides and you get L10nException('Placeholder is used as plural/select/datetime in certain languages.'), or a type conflict like Placeholders used in plurals must be of type 'num' or 'int'.
Catch it before CI: diff placeholder sets across every ARB
Drop this in tool/check_placeholders.dart. It extracts every {identifier} and {identifier, ...} from each message, tags ICU-argument usage separately from plain interpolation, and fails non-zero on any drift from the template.
// tool/check_placeholders.dart — dart run tool/check_placeholders.dart
import 'dart:convert';
import 'dart:io';
const dir = 'lib/l10n';
const templateFile = 'app_en.arb';
final _arg = RegExp(r'\{\s*([A-Za-z_]\w*)\s*([,}])');
Map<String, Set<String>> read(File f) => {
for (final e in (jsonDecode(f.readAsStringSync()) as Map<String, dynamic>).entries)
if (!e.key.startsWith('@') && e.value is String)
e.key: _arg
.allMatches(e.value as String)
.map((m) => '${m.group(1)}${m.group(2) == ',' ? ':icu' : ''}')
.toSet(),
};
void main() {
final base = read(File('$dir/$templateFile'));
var bad = false;
for (final f in Directory(dir).listSync().whereType<File>().where(
(f) => f.path.endsWith('.arb') && !f.path.endsWith(templateFile))) {
read(f).forEach((key, found) {
final want = base[key];
if (want == null) return; // key not in template: gen-l10n ignores it
if (want.length == found.length && want.containsAll(found)) return;
bad = true;
stderr.writeln('${f.uri.pathSegments.last} "$key"\n'
' template: ${want.toList()..sort()}\n'
' found: ${found.toList()..sort()}');
});
}
stdout.writeln(bad ? 'Placeholder mismatch.' : 'Placeholders consistent.');
exit(bad ? 1 : 0);
}
Against the broken files above it prints:
app_fr.arb "welcomeBack"
template: [name]
found: []
app_fr.arb "cartItems"
template: [count, count:icu]
found: []
app_ar.arb "welcomeBack"
template: [name]
found: [nom]
Placeholder mismatch.
Three real defects, each named by file and key — which is exactly what the compiler refused to tell you. Wire it in ahead of the build:
- run: dart run tool/check_placeholders.dart
- run: flutter gen-l10n
The :icu tag is deliberately strict: a locale that flattens {count, plural, ...} into a bare {count} gets flagged, because that's the runtime bug above. If a language genuinely needs a form without the numeral, add a small allowlist rather than loosening the comparison.
The fixed pair
// lib/l10n/app_fr.arb
{
"@@locale": "fr",
"welcomeBack": "Bon retour, {name} !",
"cartItems": "{count, plural, =0{Votre panier est vide} =1{1 article dans votre panier} other{{count} articles dans votre panier}}"
}
// lib/l10n/app_ar.arb
{
"@@locale": "ar",
"welcomeBack": "مرحبًا بعودتك يا {name}!",
"cartItems": "{count, plural, zero{سلتك فارغة} one{عنصر واحد في سلتك} two{عنصران في سلتك} few{{count} عناصر في سلتك} many{{count} عنصرًا في سلتك} other{{count} عنصر في سلتك}}"
}
Note that only the template carries @key metadata — translated files don't need to repeat placeholders blocks, and if they declare a type that disagrees with the template, gen-l10n throws a type-mismatch L10nException naming the locale.
Stop shipping the ARBs raw
A regex gate is the right last line of defence, but the cheaper fix is not handing translators a JSON file where deleting {count} is a plausible edit. FlutterLocalisation gives you an ARB editor over your app_<locale>.arb files so translators work in a UI instead of raw JSON, translation management across every locale you ship, and ICU plural-syntax validation that flags a locale missing a plural category the language actually needs — the exact Arabic few/many gap that otherwise waits until a user hits three items in their cart.
More on the format and the surrounding errors: the complete ARB guide and fixing common Flutter localization build errors.
Try FlutterLocalisation free — import your existing ARB files and see which locales are already drifting.