Flutter ARB select + gender: nest plural inside select
"He has 3 posts" / "She has 3 posts" is two variables in one sentence: a gender select and a count plural. gen-l10n compiles that, but only in one shape, and only if the placeholder metadata is right. Here is the exact ARB that works on Flutter 3.47, the Dart it generates, and the per-locale failures that never raise an error.
The shape that compiles: plural nested inside each select branch
ICU allows arbitrary nesting, and since the parser rewrite in Flutter 3.7 so does gen-l10n. The rule that matters: the plural goes inside each select branch, repeated in full, once per branch.
{
"@@locale": "en",
"profilePostCount": "{gender, select, male{{name} added {count, plural, =0{no posts} one{1 post} other{{count} posts}} to his profile} female{{name} added {count, plural, =0{no posts} one{1 post} other{{count} posts}} to her profile} other{{name} added {count, plural, =0{no posts} one{1 post} other{{count} posts}} to their profile}}",
"@profilePostCount": {
"description": "Gendered summary of how many posts a user added.",
"placeholders": {
"gender": { "type": "String", "example": "female" },
"name": { "type": "String", "example": "Amina" },
"count": { "type": "int", "example": "3" }
}
}
}
Three things are load-bearing:
- Every branch repeats the whole plural. There is no way to share it.
male,femaleandothereach carry a complete{count, plural, ...}. otheris mandatory on both constructs — the select and every nested plural.- The
@metadata block is not optional here. Simple messages can skip resource attributes; plural messages cannot.
What gen-l10n actually generates
With a minimal l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
flutter gen-l10n writes into lib/l10n/ (the old synthetic package:flutter_gen import is deprecated — import the generated file directly, e.g. import 'package:my_app/l10n/app_localizations.dart';). In app_localizations_en.dart you get:
@override
String profilePostCount(String gender, String name, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
zero: 'no posts',
one: '1 post',
other: '$count posts',
);
// _temp1 and _temp2 are the identical plurals from the other two branches
String _temp3 = intl.Intl.selectLogic(
gender,
{
'male': '$name added $_temp0 to his profile',
'female': '$name added $_temp1 to her profile',
'other': '$name added $_temp2 to their profile',
},
);
return '$_temp3';
}
The nesting is flattened: each plural is hoisted into its own local, and the select map interpolates them. That means all three plurals are evaluated on every call, not just the matching branch. Harmless here, worth knowing if a branch does expensive number formatting.
The call site takes positional parameters, in the order you declared them under placeholders:
Text(
AppLocalizations.of(context)!.profilePostCount(
user.gender, // any String; unmatched values fall through to 'other'
user.displayName,
user.postCount,
),
)
Intl.selectLogic matches the key exactly and is case-sensitive: 'Female' does not hit female, it silently lands on other. Normalize the gender token to lowercase in Dart before passing it.
Placeholder types: what gets inferred, what throws
Since the 3.7 parser, gen-l10n walks every locale's parse tree and infers placeholder types:
- Used in a
select→ inferredString. Declaring anything else throwsPlaceholders used in selects must be of type 'String'. - Used in a
plural→ inferrednum. Onlynumorintare accepted, otherwisePlaceholders used in plurals must be of type 'num' or 'int'. - Used as a plural in one locale and a select in another →
Placeholder is used as plural/select/datetime in certain languages. - Anything else you did not declare → typed
Object.
That last one is the quiet failure. Drop name from placeholders and the build still succeeds — but the signature becomes profilePostCount(String gender, int count, Object name). Undeclared placeholders are appended after the declared ones and sorted alphabetically, so omitting one silently reorders your arguments and interpolates Object.toString(). Declare all three, with explicit types.
"A placeholder next to a plural breaks parsing" — that was Flutter 3.3 and earlier
Older answers still warn you never to put a plain {title} next to a {count, plural, ...} in one message. That was real: flutter#110329 showed the generated Dart truncating to 'Event "$pluralString between two events.', dropping the title placeholder entirely. The old generator was regex-based and assumed at most one plural per message.
It was closed as a duplicate of #86906, which was fixed by PR #112390 — a real lex → LL-parse → codegen pipeline that first shipped in Flutter 3.7. On any current SDK, mixing placeholders, plurals and selects in one string is supported and nesting is recursive. If you are still carrying a "split it into three ARB keys and concatenate in Dart" workaround from 2022, delete it.
The new parser also emits real errors instead of silent garbage:
ICU Syntax Error: Select expressions must have an "other" case.
ICU Syntax Error: Plural expressions must have an "other" case.
ICU Syntax Error: Select parts must be of the form "identifier { message }"
ICU Syntax Error: Plural expressions case must be one of "zero", "one", "two", "few", "many", or "other".
ICU Syntax Error: Expected "}" but found ",".
The =1 / one collision
gen-l10n maps =0 → zero, =1 → one, =2 → two onto Intl.pluralLogic's named arguments. So writing both =1{1 post} and one{{count} post} in the same plural is a collision: you get ICU Syntax Warning: The plural part specified below is overridden by a later plural part, and the later part wins. Pick one spelling per slot.
One more: apostrophes are literal by default. Only if you turn on use-escaping (to get literal braces) do you have to double every ' to '' in French or Italian copy.
Where it silently breaks: each branch needs the full category set
Here is the part no error catches. Intl.pluralLogic resolves the CLDR category for the locale, then falls back:
case PluralCase.FEW: return few ?? other;
case PluralCase.MANY: return many ?? other;
case PluralCase.TWO: return two ?? few ?? other;
A missing category is not an error — it is a silent other. And because each select branch carries its own independent plural, you can complete male and forget few in female. The build passes, the analyzer is clean, and only Polish or Arabic users on the female branch see the wrong noun form.
| Locale | Categories the runtime can pick | If a branch only has one / other |
|---|---|---|
en, de, nl |
one, other | Fine |
es, fr |
one, many, other | 1,000,000 renders the other form |
pl, ru, uk, cs |
one, few, many, other | 3 (few) and 5 (many) both render other |
ar |
zero, one, two, few, many, other | 0, 2, 3 and 11 all render other |
ja, ko, zh, vi, th |
other | Fine — extra branches are dead code |
Arabic is the worst case: 3 gender branches × 6 categories = 18 sub-messages in one ARB string. The structure (fill each slot with your translator's copy):
"profilePostCount": "{gender, select, male{{name} … {count, plural, zero{…} one{…} two{…} few{…} many{…} other{…}} …} female{… the same six cases …} other{… the same six cases …}}"
Concretely, Arabic's rule sends n % 100 in 3–10 to few and 11–99 to many: count: 3 needs few, count: 11 needs many, count: 100 lands on other. Polish: 3 → few, 5 → many, 22 → few, 25 → many.
Ship checklist
- Plural nested inside each select branch, repeated in full.
- An
othercase on the select and on every nested plural. - Every placeholder declared with an explicit
type—Stringfor gender,int/numfor count. - Normalize the gender token to lowercase before calling the method.
- Never mix
=1andone(or=0/zero,=2/two) in one plural. - Per locale, confirm every gender branch has the full category set that language needs.
Step 6 is where hand-edited ARB files quietly rot — a dropped few inside one gender branch of one locale is invisible in raw JSON. FlutterLocalisation's ARB editor lets you edit app_<locale>.arb across all your locales in a UI instead of balancing braces by hand, and its ICU plural-syntax validation flags locales that are missing a plural category their language actually needs — exactly the class of bug this message shape creates. More Flutter i18n walkthroughs are on the blog, and there is a free tier — see pricing.
Try FlutterLocalisation free and stop shipping other to your Polish and Arabic users.