Fix Flutter gen-l10n Arabic Plurals (zero/two/few/many)
You translated a plural string into Arabic, filled in every CLDR category — =0, =1, =2, few, many, other — ran flutter gen-l10n, and at runtime the app shows the one or other string for counts like 3, 7, or 40. The few and many forms you carefully wrote never appear. Nothing errors. gen-l10n just silently renders the wrong grammar to every Arabic reader.
This is the single most common flutter arabic plurals gen-l10n bug, and it is almost never a gen-l10n limitation. It's an ARB-coverage problem plus one runtime rule nobody tells you about. Here's exactly what Flutter honors for ar, why =0 and zero are the same thing, and a tiny golden test that proves all six forms render.
Arabic has six plural categories, and it uses every one
English has two grammatical numbers (one, other). Arabic has six, and the CLDR rules select them by the value, not by hand:
| Category | Rule (integer n) |
Example counts |
|---|---|---|
zero |
n = 0 | 0 |
one |
n = 1 | 1 |
two |
n = 2 | 2 |
few |
n % 100 = 3..10 | 3, 7, 103, 210 |
many |
n % 100 = 11..99 | 11, 40, 99, 111 |
other |
everything else | 100, 101, 200, 1.5 |
If your ARB only supplies =0, =1, other (the English shape), then counts 3–10 fall through to other, and 11–99 fall through to other too. That's the collapse. gen-l10n did exactly what you asked — you just never gave it few or many to emit.
The ARB that actually works
Write the plural in ICU MessageFormat with all six branches. Your app_en.arb template defines the shape and placeholder; app_ar.arb supplies the Arabic strings.
lib/l10n/app_en.arb
{
"@@locale": "en",
"itemsCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemsCount": {
"description": "Number of items in a list",
"placeholders": {
"count": { "type": "int" }
}
}
}
lib/l10n/app_ar.arb
{
"@@locale": "ar",
"itemsCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}"
}
Two things to notice:
- The template (
en) only needs the branches English uses. The placeholder metadata lives on the template; thearfile just overrides the message body. - The Arabic file carries all six branches. This is the fix. Locales don't inherit each other's plural coverage — each ARB stands alone.
Make sure your l10n.yaml points at these files:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
=0 vs zero: they compile to the same argument
A persistent myth is that =0 is an "exact literal" match and zero is the "CLDR category," so you must pick carefully. In gen-l10n, both map to the same generated Intl.plural argument. gen-l10n accepts =0/=1/=2 as aliases for zero/one/two and emits identical Dart. Here's what it generates for the Arabic message above:
String itemsCount(int count) {
return intl.Intl.plural(
count,
zero: 'لا عناصر',
one: 'عنصر واحد',
two: 'عنصران',
few: '$count عناصر',
many: '$count عنصرًا',
other: '$count عنصر',
name: 'itemsCount',
args: [count],
locale: localeName,
);
}
So where does the "exact vs category" confusion come from? From Intl.plural itself at runtime. It runs with useExplicitNumberCases: true by default, which means the selection order is:
- If
count == 0andzerowas provided → usezero. - If
count == 1andonewas provided → useone. - If
count == 2andtwowas provided → usetwo. - Otherwise, look up the locale's CLDR rule and pick
zero/one/two/few/many/other, falling back tootherfor any category you didn't supply.
For Arabic, steps 1–3 and the CLDR categories agree at 0/1/2, so the distinction is invisible. The consequence that does bite you is step 4's fallback: omit few/many and every count from 3 upward that isn't 100+ silently becomes other. That is the whole bug. It also means zero/=0 works even in English (where CLDR has no zero category) — the explicit-number path fires first.
Prove it with a golden test — don't eyeball it
The reason this ships broken so often is that developers test with 0, 1, and 100 — which happen to hit zero, one, and other correctly — and never try a few or many value. Lock it down with a widget test that renders one string per category:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/l10n/app_localizations.dart';
Future<String> renderArabic(WidgetTester tester, int count) async {
late AppLocalizations l10n;
await tester.pumpWidget(MaterialApp(
locale: const Locale('ar'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(builder: (context) {
l10n = AppLocalizations.of(context)!;
return const SizedBox();
}),
));
return l10n.itemsCount(count);
}
void main() {
testWidgets('ar renders all six plural forms', (tester) async {
expect(await renderArabic(tester, 0), 'لا عناصر'); // zero
expect(await renderArabic(tester, 1), 'عنصر واحد'); // one
expect(await renderArabic(tester, 2), 'عنصران'); // two
expect(await renderArabic(tester, 3), '3 عناصر'); // few
expect(await renderArabic(tester, 11), '11 عنصرًا'); // many
expect(await renderArabic(tester, 100), '100 عنصر'); // other
});
}
The values 0, 1, 2, 3, 11, 100 are chosen deliberately — one per category. If few or many is missing from your ARB, the 3 or 11 assertion fails with the other string instead, and you catch it in CI instead of a bug report from Riyadh.
Import note: On Flutter 3.22+,
synthetic-packagedefaults tofalse, so the generated file lands in your source tree — import it aspackage:your_app/l10n/app_localizations.dart. On older setups using the synthetic package, importpackage:flutter_gen/gen_l10n/app_localizations.dartinstead. Adjust the path to match where gen-l10n writes.
The same trap hits Polish, Russian, and Welsh
This isn't Arabic-specific. Any language whose CLDR rules use few/many — Polish, Russian, Ukrainian, Czech, Welsh, Lithuanian — collapses identically if you leave those branches out. The rules differ (Russian's many covers most teens and values ending in 0; Welsh uses all six with different boundaries), but the failure mode is the same: missing category ⇒ silent fallback to other. Add a golden test per pluralized locale and the whole class of bug disappears.
Keep your ARB plural coverage honest
Managing six branches per string across a dozen locales in raw JSON is where mistakes creep back in — a stray missing many, a typo'd category name that gets ignored, an ar file that drifted out of sync with the template. FlutterLocalisation's ARB editor validates ICU plural syntax and flags locales that are missing a category the language actually needs, so a dropped few shows up before it ships. If you want the broader picture, our complete guide to Flutter pluralization walks through gender and nested selects too.
Try FlutterLocalisation free and catch missing few/many forms before your Arabic users do.