← Back to Blog

Flutter gen-l10n Nested Plurals & Selects: 3 Fixes

flutterl10ngen-l10nintlarbpluralization

Flutter gen-l10n Nested Plurals & Selects: 3 Fixes

You wrote one tidy ARB string that combines a count and a gender:

{
  "inboxSummary": "{gender, select, male{He sent {count, plural, one{1 message} other{{count} messages}}} female{She sent {count, plural, one{1 message} other{{count} messages}}} other{They sent {count, plural, one{1 message} other{{count} messages}}}}"
}

Then flutter gen-l10n either throws a parse error, silently drops text, or generates a method that returns garbage. You are not doing it wrong. Flutter's gen_l10n tool does not support nested ICU — a plural inside a select, a select inside a plural, or two plurals in a single message. This is a known, long-standing gap, tracked in flutter#75094 and flutter#86906, and it is still open.

This post explains exactly why it breaks, then gives you three copy-paste patterns that work today — no waiting on the framework.

Why nested ICU breaks gen_l10n

The ICU MessageFormat spec is recursive: any branch of a plural or select can contain another plural or select. The intl package's runtime (Intl.message, Intl.plural, Intl.select) handles this fine. The problem is the code generator.

gen_l10n parses your ARB at build time and emits a Dart method per message. For a single plural it generates a call to Intl.pluralLogic; for a single select, Intl.selectLogic. But its parser was built to recognize one top-level plural or select in a message — PR #86167 added support for putting a single plural/select in the middle of surrounding text, and that's roughly where support stops. Give it a nested or doubled construct and the generator can't map the tree to its codegen template, so it errors out or emits the wrong thing.

The key distinction: the limitation is in code generation, not in intl itself. That's exactly why every workaround below works — they all route around the generator and either compose generated methods or call the intl runtime directly.

Workaround 1: Split into composed ARB messages

The lowest-friction fix: break the one mega-string into separate ARB entries — one per gender branch, each with its own plural — and let gen_l10n generate them normally.

{
  "summaryMale": "He sent {count, plural, one{1 message} other{{count} messages}}",
  "@summaryMale": {
    "placeholders": { "count": { "type": "int" } }
  },
  "summaryFemale": "She sent {count, plural, one{1 message} other{{count} messages}}",
  "@summaryFemale": {
    "placeholders": { "count": { "type": "int" } }
  },
  "summaryOther": "They sent {count, plural, one{1 message} other{{count} messages}}",
  "@summaryOther": {
    "placeholders": { "count": { "type": "int" } }
  }
}

Each of these is a single plural, so the generator is happy. Pick the right one in Dart:

String inboxSummary(AppLocalizations l10n, int count, String gender) {
  return switch (gender) {
    'male' => l10n.summaryMale(count),
    'female' => l10n.summaryFemale(count),
    _ => l10n.summaryOther(count),
  };
}

Pros: translators still see complete, natural sentences per branch — the best outcome for translation quality, because word order and agreement stay inside one string. Cons: the message count multiplies (3 genders × N languages), and the gender-selection logic now lives in Dart instead of the ARB.

Avoid the tempting-but-wrong version of this — concatenating two independent messages like '${l10n.count(n)} ${l10n.sharedWith(gender)}'. Languages reorder clauses, and gluing fragments produces broken grammar in half your locales. Keep each branch a whole sentence.

Workaround 2: Drop to raw Intl.pluralLogic / selectLogic

If you want the nesting logic in one place but still keep translations in ARB, combine the generated leaf methods with Intl.selectLogic from the intl package. selectLogic takes the choice and a Map of cases; pluralLogic resolves a count against CLDR plural categories for the active locale.

import 'package:intl/intl.dart';

String inboxSummary(AppLocalizations l10n, int count, String gender) {
  return Intl.selectLogic(gender, {
    'male': l10n.summaryMale(count),
    'female': l10n.summaryFemale(count),
    'other': l10n.summaryOther(count),
  });
}

selectLogic requires an 'other' key or it throws ArgumentError, which conveniently forces you to handle the fallback. This is essentially what the ICU select branch would have done at runtime — you're just expressing it in Dart.

Need a fully self-contained helper that doesn't lean on generated methods at all? You can nest the two intl calls directly. This is handy for quick experiments, but note the strings are now hardcoded and outside your ARB pipeline, so prefer it only for non-translatable or developer-facing text:

String debugSummary(int count, String gender, String localeName) {
  return Intl.selectLogic(gender, {
    'male': Intl.pluralLogic(count, locale: localeName,
        one: 'He sent 1 message', other: 'He sent $count messages'),
    'female': Intl.pluralLogic(count, locale: localeName,
        one: 'She sent 1 message', other: 'She sent $count messages'),
    'other': Intl.pluralLogic(count, locale: localeName,
        one: 'They sent 1 message', other: 'They sent $count messages'),
  });
}

Intl.pluralLogic accepts named zero, one, two, few, many, and a required other — supply only the categories your locale uses. Pass localeName (you can get it from Localizations.localeOf(context).toString() or the generated AppLocalizations.of(context)!.localeName) so the correct plural rules apply.

Workaround 3: A clean, type-safe widget helper

Workarounds 1 and 2 still leave you passing stringly-typed 'male'/'female' around. Tighten it with an enum and an extension so the call site reads like a single localized message — and the compiler enforces that you handle every case.

enum Gender { male, female, other }

extension Messages on AppLocalizations {
  String inboxSummary(int count, Gender gender) => switch (gender) {
        Gender.male => summaryMale(count),
        Gender.female => summaryFemale(count),
        Gender.other => summaryOther(count),
      };
}

Now your widget tree stays declarative and there are no magic strings:

Text(
  AppLocalizations.of(context)!.inboxSummary(unreadCount, user.gender),
)

The Dart 3 switch expression is exhaustive over the enum, so adding a Gender value later is a compile error until you handle it — exactly the safety net ICU select can't give you. This is the pattern I reach for in production: ARB owns the words, the extension owns the composition, and widgets read cleanly.

Which one should you use?

  • Prototyping or one-off string: Workaround 2's self-contained helper.
  • Real, translated UI: Workaround 1 for the ARB structure, layered with Workaround 3's enum + extension for ergonomics and type safety.
  • Many call sites sharing one nested message: Workaround 2's selectLogic-over-generated-methods, wrapped in the extension.

All three keep CLDR plural rules intact, because the actual pluralization always runs through gen_l10n's generated pluralLogic or intl directly — you're only choosing where the select branch lives.

Keep your ARB files sane

The real cost of these workarounds is bookkeeping: three messages per nested string, multiplied across every locale, all needing matching placeholders. That's where a dedicated editor pays off. FlutterLocalisation's ARB editor validates placeholders, flags missing plural categories per language, and keeps your split messages in sync so a refactor like this doesn't leave half your locales broken. See pricing for team plans.

Try FlutterLocalisation free and stop hand-editing nested ICU by hand.