← Back to Blog

Flutter ARB Plurals: Why Russian Falls Back to other

flutteri18narbicupluralsgen-l10n

Flutter ARB Plurals: Why Russian Falls Back to other

Your Russian users report that the app says «3 сообщений» instead of «3 сообщения». flutter gen-l10n exits 0. flutter analyze is clean. There is no warning anywhere in your build log.

The cause is almost always the same: your app_ru.arb has a plural with only one and other, and package:intl quietly resolves the CLDR few category down to other at runtime.

What gen-l10n actually validates

Flutter's ICU message parser hard-fails on exactly three plural problems:

  1. Missing otherICU Syntax Error: Plural expressions must have an "other" case.
  2. An unrecognized case name — the cases must be one of =0, =1, =2, zero, one, two, few, many, other.
  3. Malformed braces.

That's it. There is no check that a locale supplies the categories its language grammatically requires. few and many are treated as optional everywhere, in every locale, including the ones where they carry most of the message.

This is also why untranslated-messages-file in your l10n.yaml doesn't help — it reports missing keys, not missing categories inside a key that is present.

The generated Dart, verbatim

Start with a Russian ARB that a vendor or MT pass returned with English's two-form shape:

{
  "@@locale": "ru",
  "unreadMessages": "{count, plural, one{{count} сообщение} other{{count} сообщений}}"
}

gen-l10n builds its plural arguments from only the cases present in the ARB, then drops them into pluralVariableTemplate. The output in app_localizations_ru.dart is:

class AppLocalizationsRu extends AppLocalizations {
  AppLocalizationsRu([String locale = 'ru']) : super(locale);

  @override
  String unreadMessages(int count) {
    String _temp0 = intl.Intl.pluralLogic(
      count,
      locale: localeName,
      one: '$count сообщение',
      other: '$count сообщений',
    );
    return '$_temp0';
  }
}

Look at what is not there. few: and many: are absent — and in Intl.pluralLogic those are optional named parameters, so they default to null. Only other is required.

Now the payoff, from package:intl's own source:

var pluralRule = _pluralRule(locale, howMany, precision);
var pluralCase = pluralRule();
switch (pluralCase) {
  case plural_rules.PluralCase.ZERO:  return zero ?? other;
  case plural_rules.PluralCase.ONE:   return one  ?? other;
  case plural_rules.PluralCase.TWO:   return two  ?? few ?? other;
  case plural_rules.PluralCase.FEW:   return few  ?? other;
  case plural_rules.PluralCase.MANY:  return many ?? other;
  case plural_rules.PluralCase.OTHER: return other;
}

few ?? other. The Russian rule correctly classifies 3 as FEW — and then hands you the other string because few is null. Grammatically wrong, technically working as designed.

The CLDR categories each language actually uses

These come straight from plural_rules.dart in package:intl. Integers only:

Locale Categories used one few many other
ru, uk one, few, many, other 1, 21, 31, 101 2–4, 22–24, 102–104 0, 5–20, 25–30, 111 fractions only (1.5)
pl one, few, many, other only 1 2–4, 22–24 0, 5–21, 11–14, 21, 31 fractions only
ar zero, one, two, few, many, other 1 (zero=0, two=2) n%100 = 3–10 n%100 = 11–99 100, 101, 102, 200
cy zero, one, two, few, many, other 1 exactly 3 exactly 6 4, 5, 7+
lv zero, one, other 1, 21, 101 2–9, 22–29

Three things to notice:

  • Polish is not Russian. 21 is one in Russian but many in Polish. Copying a Russian ARB's category split into app_pl.arb produces a different bug.
  • Arabic's other is reachable for integers (100, 101, 102) — unlike ru/pl, where other only ever fires on fractions. An Arabic ARB needs all six arms.
  • Latvian has no few/many at all. Its trap is a dropped zero, which covers 0, 10, and 11–19. Same failure mode, different category.

The corrected ARB entries

Russian — other handles fractional counts, which take the genitive singular:

{
  "@@locale": "ru",
  "unreadMessages": "{count, plural, one{{count} сообщение} few{{count} сообщения} many{{count} сообщений} other{{count} сообщения}}"
}

Polish:

{
  "@@locale": "pl",
  "unreadMessages": "{count, plural, one{{count} wiadomość} few{{count} wiadomości} many{{count} wiadomości} other{{count} wiadomości}}"
}

Arabic, all six arms:

{
  "@@locale": "ar",
  "unreadMessages": "{count, plural, zero{لا توجد رسائل} one{رسالة واحدة} two{رسالتان} few{{count} رسائل} many{{count} رسالة} other{{count} رسالة}}"
}

Note that your template ARB (usually app_en.arb) still only needs one and other — gen-l10n does not require the target locales to mirror the template's category set. That asymmetry is exactly why this slips through review.

A test that locks it in

Drop this in test/plural_test.dart. It pumps a real locale through MaterialApp and asserts the boundary counts where the categories switch:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';

Future<AppLocalizations> pumpLocale(WidgetTester tester, Locale locale) async {
  late AppLocalizations l10n;
  await tester.pumpWidget(MaterialApp(
    locale: locale,
    localizationsDelegates: AppLocalizations.localizationsDelegates,
    supportedLocales: AppLocalizations.supportedLocales,
    home: Builder(builder: (context) {
      l10n = AppLocalizations.of(context)!;
      return const SizedBox.shrink();
    }),
  ));
  return l10n;
}

void main() {
  testWidgets('ru uses one/few/many, not just other', (tester) async {
    final l10n = await pumpLocale(tester, const Locale('ru'));

    expect(l10n.unreadMessages(1),   '1 сообщение');    // one
    expect(l10n.unreadMessages(2),   '2 сообщения');    // few
    expect(l10n.unreadMessages(5),   '5 сообщений');    // many
    expect(l10n.unreadMessages(11),  '11 сообщений');   // many (the 11–14 exception)
    expect(l10n.unreadMessages(21),  '21 сообщение');   // one
    expect(l10n.unreadMessages(101), '101 сообщение');  // one
  });

  testWidgets('pl differs from ru at 21', (tester) async {
    final l10n = await pumpLocale(tester, const Locale('pl'));

    expect(l10n.unreadMessages(1),  '1 wiadomość');    // one
    expect(l10n.unreadMessages(2),  '2 wiadomości');   // few
    expect(l10n.unreadMessages(21), '21 wiadomości');  // many — NOT one
  });
}

If you'd rather skip the widget pump, await AppLocalizations.delegate.load(const Locale('ru')) returns the same object synchronously. Plural rules are compiled-in Dart, so no initializeMessages call is needed.

The critical part is the choice of counts. Testing 1 and 5 alone passes on a broken ARB in Russian, because many and the fallback other often carry the same string. 2 and 11 are the counts that actually fail.

The inverse trap: few/many where they can't fire

Going the other way is just as common — a translator or a well-meaning script pads every locale with all six arms:

{
  "@@locale": "ja",
  "unreadMessages": "{count, plural, one{{count}件のメッセージ} few{...} many{...} other{{count}件のメッセージ}}"
}

Japanese and Chinese map to intl's default rule, which returns OTHER for every value. English's rule only ever returns ONE or OTHER. So in en, ja, and zh, the few and many arms are dead code: never selected, never compiled away, and silently shipped as strings your translators keep re-reviewing.

One genuine exception is worth knowing. Before consulting the CLDR rule, pluralLogic checks for explicit exact-number matches:

if (howMany == 0 && zero != null) return zero;
if (howMany == 1 && one  != null) return one;
if (howMany == 2 && two  != null) return two;

So a one{} arm in a Japanese ARB does fire for exactly 1, even though ja has no one category — and a zero{} arm you add to Russian does fire for 0, overriding CLDR's many. That's usually what you want («Нет сообщений»), but it's a deliberate deviation from CLDR, not a bug. few and many get no such shortcut.

Catch it before the bug report

The structural fix is to validate category completeness per locale as part of your translation workflow, not hope a reviewer spots a missing brace pair in raw JSON.

FlutterLocalisation does exactly this: its ICU plural-syntax validation flags any locale missing a plural category the language actually needs — a dropped few/many in Russian, Polish or Arabic, a dropped zero in Latvian — and the ARB editor gives translators the category arms as labelled fields on your app_<locale>.arb files instead of hand-edited ICU strings.

While you're tightening the pipeline, it's worth reviewing your l10n.yaml configuration too — and the rest of the Flutter i18n guides.

Try FlutterLocalisation free — import your ARBs and see which locales are missing plural categories in a few seconds.