Every example below was tested with Flutter 3.47.5 and flutter gen-l10n, using an app_en.arb template and an app_fr.arb translation.
Generate each language's plural block right in your browser with a free tool, without signup or upload.
What gen-l10n does block
ICU syntax is checked in every file, not just the template. A plural without an other branch stops generation, with the file name and the position of the error:
[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
{count, plural, one{{count} article}}
^
An unclosed brace stops generation the same way. You see those errors right away; the cases below are harder to spot.
A dropped placeholder goes through without a word
The English template declares a parameter:
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }
The French translation lost it: "welcomeUser": "Bon retour parmi nous !". Generation succeeds, the analyzer is quiet, and the generated method simply ignores its argument:
@override
String welcomeUser(String name) {
return 'Bon retour parmi nous !';
}
Nothing crashes, but French-speaking users never see their name. Sometimes that's deliberate (the sentence reads better without it), usually it's a mistake, and no standard tool tells the two apart. You have to compare each translation's placeholders with the template's; there's a script for that further down.
A renamed placeholder changes the API of every locale
Here the translation uses a name the template doesn't have, say {nombre} instead of {count}:
"reminder": "Il reste {nombre} articles dans {listName}."
gen-l10n still reports nothing. It adds nombre as an extra parameter, on the abstract class, so in every locale:
String reminder(String listName, int count, Object nombre);
The error shows up later, when each existing call site compiles: Too few positional arguments: 3 required, 2 given. It points at your Dart code, not at the ARB file that caused it. If a localization method suddenly grows an Object parameter, search your translations for that name.
In French, one also covers zero
The one, few, many… categories follow each language's CLDR rules. In English, one only means 1. In French it means 0 and 1. With this message:
"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
| count | English | French |
|---|---|---|
| 0 | 0 items | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 items | 2 articles |
"0 article" is correct French. The problem comes from exact matches: gen-l10n turns =0 and =1 into the zero and one arguments of Intl.pluralLogic. An =1 branch therefore becomes the one category, and in French that category also applies to zero:
"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"
With count = 0, English shows "0 items left" and French shows "1 article restant" (one item left). The text is wrong and nothing flags it. To fix it, add an =0 branch, or write one{{count} article restant} so the number shown is always right. Any language where one covers more than 1 has the same issue.
Apostrophes and use-escaping
By default, a straight apostrophe is just a character: "Don't forget the milk" works. If you turn on use-escaping: true in l10n.yaml (to write literal braces), it becomes the ICU escape character and a lone apostrophe stops generation:
[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
Don't forget the milk
^
You then have to double it (Don''t) or use the typographic apostrophe ’, which isn't affected. On an existing project, flipping that option means going back over every language; English contractions and French elisions will keep you busy.
A check worth adding to CI
The silent case is the dropped placeholder. This script reads the placeholders declared in the template and checks that each translation still uses them:
import 'dart:convert';
import 'dart:io';
void main() {
final template = _read('lib/l10n/app_en.arb');
var errors = 0;
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb') || file.path.endsWith('app_en.arb')) continue;
final translation = _read(file.path);
for (final key in template.keys.where((k) => !k.startsWith('@'))) {
final text = translation[key];
if (text is! String) continue; // missing key: see untranslated-messages-file
final meta = template['@$key'] as Map<String, dynamic>?;
final declared = (meta?['placeholders'] as Map<String, dynamic>?)?.keys ?? const <String>[];
for (final name in declared) {
if (!RegExp(r'\{\s*' + name + r'\s*[,}]').hasMatch(text)) {
stderr.writeln('${file.path} · $key: {$name} missing');
errors++;
}
}
}
}
if (errors > 0) exit(1);
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
On the example from this page, it prints lib/l10n/app_fr.arb · welcomeUser: {name} missing and exits with an error. Its limits: it only looks at placeholders declared in @key.placeholders, and for a plural it checks that the variable appears somewhere in the message, not in every branch. The two plural traps above are best caught with a test that calls your messages with 0, 1 and 2.