← Back to Blog

Flutter gen-l10n: Plural + Placeholder in One ARB String

flutterl10narbicugen-l10n

Flutter gen-l10n: Plural + Placeholder in One ARB String

You write a message that needs a count and a second variable in the same sentence, run flutter gen-l10n, and something is wrong: the parameters come out in an order you did not ask for, the call site stops compiling, or the tool dies with ICU Syntax Error. This is the single most common way an ARB file with a plural goes sideways, and it has three different root causes that look identical from the outside.

Everything below is checked against the gen_l10n implementation in current Flutter stable (3.47.x). The old truncation bug, where the generated Dart chopped the sentence off after the plural, was a real regex-parser bug in Flutter 3.0/3.1 (flutter/flutter#110329, #109498) and is gone since the ICU parser rewrite in 3.7. If you are still on 3.0-3.3, upgrade first, because no ARB trick fixes that one.

The ARB that breaks

{
  "eventsInCity": "{count, plural, =0{No events} =1{1 event} other{{count} events}} in {city}",
  "@eventsInCity": {
    "description": "Number of events happening in a city",
    "placeholders": {
      "city": { "type": "String" }
    }
  }
}

This parses fine. That is the problem. You call it the way you read it:

Text(AppLocalizations.of(context)!.eventsInCity(3, 'Lisbon'));
// error: The argument type 'int' can't be assigned to the parameter type 'String'.

Because the generated method is:

  @override
  String eventsInCity(String city, num count) {
    String _temp0 = intl.Intl.pluralLogic(
      count,
      locale: localeName,
      zero: 'No events',
      one: '1 event',
      other: '$count events',
    );
    return '$_temp0 in $city';
  }

city came first even though count appears first in the sentence.

Why the sibling placeholder wins

Parameter order in gen-l10n comes from the placeholders map, not from the message text. generateMethodParameters walks message.templatePlaceholders in map order, and since json.decode preserves key insertion order, that is literally the order you typed the keys in the ARB.

The twist is what happens to a placeholder you did not declare. gen-l10n infers it, and inferred placeholders are appended after the declared ones, sorted alphabetically:

templatePlaceholders.addEntries(
  undeclaredPlaceholders.entries.toList()
    ..sort((p1, p2) => p1.key.compareTo(p2.key)),
);

A plural control variable is easy to leave undeclared, because it looks like syntax rather than an argument. It is an argument. Inference also picks the type for you: a placeholder used in a plural becomes num, one used in a select becomes String, anything else becomes Object.

That Object fallback is where you get garbled output instead of a compile error. Declare nothing at all and both parameters end up loosely typed, the swapped call compiles, and the app renders Lisbon in 3. Nothing warns you. This is the real shape of "flutter l10n multiple placeholders plural not working": the generator did exactly what it was told, and what it was told was incomplete.

Fix 1: declare every placeholder, in the order you want them

This is the fix for 90% of cases. Declare the plural variable explicitly and put it first.

{
  "eventsInCity": "{count, plural, =0{No events} =1{1 event} other{{count} events}} in {city}",
  "@eventsInCity": {
    "description": "Number of events happening in a city",
    "placeholders": {
      "count": { "type": "int", "example": "3" },
      "city": { "type": "String", "example": "Lisbon" }
    }
  }
}

Generated Dart:

  @override
  String eventsInCity(int count, String city) {
    String _temp0 = intl.Intl.pluralLogic(
      count,
      locale: localeName,
      zero: 'No events',
      one: '1 event',
      other: '$count events',
    );
    return '$_temp0 in $city';
  }

Two rules worth pinning to the wall:

  • A plural placeholder must be num or int. Anything else throws Placeholders used in plurals must be of type 'num' or 'int'. A select placeholder must be String.
  • Inside a plural branch you still write {count} to print the number. other{events} silently drops it.

If you never want to think about order again, turn on named parameters in l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
use-named-parameters: true
  @override
  String eventsInCity({required int count, required String city}) {

Call sites become eventsInCity(count: 3, city: 'Lisbon'), and reordering the ARB keys can no longer break them. It is a breaking change to every existing call, so do it in one commit.

Fix 2: escape literal braces next to a plural

Sometimes the second thing in the sentence is not a placeholder at all, it is braces you want printed: a code sample, a glob, a JSON snippet. The lexer does not care about your intent. It sees { and starts reading an identifier, then fails with ICU Lexing Error: Unexpected character. or ICU Syntax Error: Expected "}" but found ....

Enable escaping in l10n.yaml:

use-escaping: true

Then wrap the literal chunk in single quotes, and double a real apostrophe:

{
  "filesMatched": "{count, plural, =1{1 file} other{{count} files}} matched '{glob}'",
  "@filesMatched": {
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}
  @override
  String filesMatched(int count) {
    String _temp0 = intl.Intl.pluralLogic(
      count,
      locale: localeName,
      one: '1 file',
      other: '$count files',
    );
    return '$_temp0 matched {glob}';
  }

The quoted '{glob}' is printed verbatim and never becomes a parameter. Turning use-escaping on is global, so every existing single quote in every ARB file starts meaning something: audit for stray apostrophes and double them (Isn''t) in the same pass. If you only need the parser to stop choking on stray braces without adopting quote semantics, relax-syntax: true is the lighter option.

Fix 3: split the message when the language reorders it

Fixes 1 and 2 assume the plural chunk and the sibling stay in that order in every locale. Often they do not. German, Arabic and Japanese translators regularly want the location first, and they cannot move a plural block past a placeholder without you re-approving the whole string. Split it:

{
  "eventCount": "{count, plural, =0{No events} =1{1 event} other{{count} events}}",
  "@eventCount": {
    "description": "Just the count phrase, composed into eventsInCity",
    "placeholders": {
      "count": { "type": "int" }
    }
  },
  "eventsInCity": "{events} in {city}",
  "@eventsInCity": {
    "description": "Wraps eventCount with a location. Translators may reorder freely.",
    "placeholders": {
      "events": { "type": "String" },
      "city": { "type": "String" }
    }
  }
}
  @override
  String eventCount(int count) {
    String _temp0 = intl.Intl.pluralLogic(
      count,
      locale: localeName,
      zero: 'No events',
      one: '1 event',
      other: '$count events',
    );
    return '$_temp0';
  }

  @override
  String eventsInCity(String events, String city) {
    return '$events in $city';
  }

Call site:

final l10n = AppLocalizations.of(context)!;
Text(l10n.eventsInCity(l10n.eventCount(3), 'Lisbon'));

A German ARB can now ship "eventsInCity": "In {city}: {events}" with no change to your Dart. The cost is real, so use it deliberately: the translator sees the two halves separately and loses the ability to make the wrapper agree in case or gender with the inner phrase. When the surrounding words have to change with the count, keep one message and use Fix 1.

Quick triage

  • Wrong argument order or a sudden type error at the call site: a placeholder is undeclared, or your placeholders keys are in a different order than the method signature you expected.
  • ICU Lexing Error: Unexpected character.: an unescaped literal brace, or a character the lexer rejects. Identifiers match [a-zA-Z0-9|_]+ only.
  • ICU Syntax Error: Plural expressions must have an "other" case.: every plural needs other, in every locale.
  • The sentence renders but the number is missing: you wrote other{events} instead of other{{count} events}.
  • Output looks right in English and wrong in Polish or Arabic: a plural category the language needs (few, many) is missing from that locale's ARB. gen-l10n will not tell you, because other satisfies it.

That last one is exactly what the FlutterLocalisation ARB editor catches: ICU plural-syntax validation flags a locale that dropped a category its language actually requires, before the build ships. You edit app_<locale>.arb in a UI with placeholders visible per message instead of hand-balancing braces in raw JSON across a dozen files. If you want the fundamentals of the format first, our complete guide to ARB files covers the structure these messages live in, and pricing has a free tier.

Try FlutterLocalisation free and let the validator find the plural you broke.