ICU plurals and placeholders: what gen-l10n lets through

Updated · LocalePilot

flutter gen-l10n rejects a malformed ICU message, but it accepts, without a warning, a translation that lost its placeholder or a plural that will say "1 item" for zero. The examples below show what it checks and what it lets through, followed by a script that catches dropped placeholders.

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}}"
countEnglishFrench
00 items0 article
11 item1 article
22 items2 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.

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.