← Back to Blog

Fix Flutter gen-l10n Arabic Plurals (zero/two/few/many)

flutterl10narbarabicpluralizationgen-l10n

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; the ar file 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:

  1. If count == 0 and zero was provided → use zero.
  2. If count == 1 and one was provided → use one.
  3. If count == 2 and two was provided → use two.
  4. Otherwise, look up the locale's CLDR rule and pick zero/one/two/few/many/other, falling back to other for 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-package defaults to false, so the generated file lands in your source tree — import it as package:your_app/l10n/app_localizations.dart. On older setups using the synthetic package, import package:flutter_gen/gen_l10n/app_localizations.dart instead. 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.