ICU plurals and placeholders: what gen-l10n lets through

Updated · GLabs325

flutter gen-l10n rejects a malformed ICU message, but quietly accepts a translation that lost its placeholder, or a plural that will say "1 item" for zero. Here is what it checks, what it doesn't, and a script to close the gap.

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.

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}}
                    ^

Same for an unclosed brace. You see those right away. The next ones, you don't.

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. French-speaking users just 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

This one is stranger. 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}}"
countEnglishFrench
00 items0 article
11 item1 article
22 items2 articles

"0 article" is correct French, so no problem there. The trap is elsewhere: 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). Wrong text, and nothing flags it. The fix: 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.

Frequently asked questions

Does flutter gen-l10n check translations or only the template?

It checks the ICU syntax of every ARB file. It does not compare a translation's placeholders with the template's: a dropped placeholder goes through without a warning.

Why does my localization method have an extra Object parameter?

A translation uses a placeholder name that doesn't exist in the template, for example {nombre} instead of {count}. gen-l10n adds it as a parameter in every locale, and existing call sites stop compiling.

=1 or one in a plural?

In ICU, =1 targets exactly the value 1, but gen-l10n passes it as the one category, which also covers 0 in French. Add an =0 branch or show {count} in the one branch.

More guides