Flutter gen-l10n Rejects selectordinal: Localize Ordinals
You paste a standard ICU message into app_en.arb to get "1st place", run flutter gen-l10n, and the build stops. The message is valid ICU. The problem is that gen-l10n only implements part of ICU MessageFormat, and selectordinal is outside that part.
Everything below was run on Flutter 3.47.4 with intl 0.20.2.
The exact error
This ARB entry is what most ICU guides tell you to write:
{
"finishedPlace": "You finished {place, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}",
"@finishedPlace": {
"placeholders": { "place": { "type": "int" } }
}
}
flutter gen-l10n answers with:
[app_en.arb:finishedPlace] ICU Syntax Error: Expected "}" but found ",".
You finished {place, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}
^
Found syntax errors.
The error never mentions selectordinal, which is why it is hard to search for. The caret points at the first comma, and the reason is in the parser. After {place, gen-l10n looks at the next token. If it is plural it parses a plural, if it is select it parses a select, and date or time start a formatted argument. Anything else is read as a plain {place} placeholder, so the parser expects a closing brace and finds a comma.
One broken message fails generation for every locale. If you are not sure which key is the culprit, the triage steps in Fix Flutter gen-l10n "Found Syntax Errors" in Your ARB apply here too.
Why plural is the wrong substitute
The tempting fix is to swap the keyword:
"finishedPlace": "You finished {place, plural, one{#st} two{#nd} few{#rd} other{#th}}"
This compiles, and it prints "2th" and "3th". plural uses cardinal rules (how many), and ordinals use a separate rule set (which position). CLDR defines both per language, and they rarely match:
| Language | Cardinal categories | Ordinal categories |
|---|---|---|
| English | one, other | one, two, few, other |
| Swedish | one, other | one, other (different rule) |
| French | one, many, other | one, other |
| Spanish | one, many, other | other |
English cardinal rules never return two or few, so those branches are dead code. English cardinal one also only matches 1, while ordinal one has to match 21, 31 and 101 but skip 11.
Explicit cases such as =1{1st} =2{2nd} only move the bug. They are correct up to 20 and wrong at 21, 22 and 23.
intl's MessageFormat will not save you either
The intl package ships a runtime MessageFormat class that does parse selectordinal. In intl 0.20.3 its ordinal rule lookup calls the same cardinal Intl.pluralLogic used for plural, so English still comes out as "2th". There is no flutter intl ordinal suffix API to call today. You have to supply the category yourself.
Workaround 1: compute the category in Dart, select in the ARB
gen-l10n does support select, and a select does not care where its string comes from. So compute the ordinal category in Dart and pass it in as a second placeholder. Translators still own the suffixes.
The helper, with the CLDR ordinal rules for four languages:
/// CLDR ordinal plural category for [n] in [languageCode].
/// Add a case for each language you ship.
String ordinalCategory(int n, String languageCode) {
final mod10 = n % 10;
final mod100 = n % 100;
switch (languageCode) {
case 'en':
if (mod10 == 1 && mod100 != 11) return 'one';
if (mod10 == 2 && mod100 != 12) return 'two';
if (mod10 == 3 && mod100 != 13) return 'few';
return 'other';
case 'sv':
if ((mod10 == 1 || mod10 == 2) && mod100 != 11 && mod100 != 12) {
return 'one';
}
return 'other';
case 'fr':
return n == 1 ? 'one' : 'other';
default:
return 'other';
}
}
The template ARB:
{
"@@locale": "en",
"attemptLabel": "{n}{category, select, one{st} two{nd} few{rd} other{th}} attempt",
"@attemptLabel": {
"placeholders": {
"n": { "type": "int" },
"category": { "type": "String" }
}
}
}
Each locale only lists the categories it needs:
{ "@@locale": "sv", "attemptLabel": "{n}{category, select, one{:a} other{:e}} försöket" }
{ "@@locale": "fr", "attemptLabel": "{n}{category, select, one{re} other{e}} tentative" }
{ "@@locale": "es", "attemptLabel": "{n}.º intento" }
And the call site:
final l10n = AppLocalizations.of(context)!;
final code = Localizations.localeOf(context).languageCode;
Text(l10n.attemptLabel(n, ordinalCategory(n, code)));
A nice side effect: because the suffix sits inside the sentence, the translator picks the gender. "Tentative" is feminine in French, so the French string uses re for 1 and nobody has to pass a gender around.
If you already combine counts and names in one message, the same placeholder rules apply as in Plural + Placeholder in One ARB String.
Workaround 2: a full-string ordinal placeholder for gendered languages
Workaround 1 breaks down when the ordinal is reused next to different nouns, for example in a leaderboard column or a date. In French, 1 is "1er" or "1re" depending on the noun. In Spanish every ordinal is "1.º" or "1.ª".
Build the ordinal as its own message with a gender select, then pass the finished string into the sentence as a plain String placeholder.
Template:
{
"@@locale": "en",
"ordinal": "{n}{category, select, one{st} two{nd} few{rd} other{th}}",
"@ordinal": {
"placeholders": {
"n": { "type": "int" },
"category": { "type": "String" },
"gender": { "type": "String" }
}
},
"finishedIn": "You finished in {position} place",
"@finishedIn": {
"placeholders": { "position": { "type": "String" } }
}
}
English declares gender and ignores it. French nests a gender select inside the one branch, and Spanish only needs gender:
{
"@@locale": "fr",
"ordinal": "{n}{category, select, one{{gender, select, feminine{re} other{er}}} other{e}}",
"finishedIn": "Vous avez terminé à la {position} place"
}
{
"@@locale": "es",
"ordinal": "{n}{gender, select, feminine{.ª} other{.º}}",
"finishedIn": "Has terminado en {position} posición"
}
Usage:
final position = l10n.ordinal(n, ordinalCategory(n, code), 'feminine');
Text(l10n.finishedIn(position));
The catch is that the gender belongs to the noun in the target language. "Place" is feminine in French, and "puesto" would be masculine in Spanish. When the noun is fixed, prefer workaround 1 and let the translator write the suffix inline. Use workaround 2 when the noun is dynamic and your data already knows its gender.
Unit test table for en, fr, es and sv
These are the expected outputs for ordinal(n, ordinalCategory(n, code), 'masculine'):
| n | en | fr | es | sv |
|---|---|---|---|---|
| 1 | 1st | 1er | 1.º | 1:a |
| 2 | 2nd | 2e | 2.º | 2:a |
| 3 | 3rd | 3e | 3.º | 3:e |
| 11 | 11th | 11e | 11.º | 11:e |
| 12 | 12th | 12e | 12.º | 12:e |
| 21 | 21st | 21e | 21.º | 21:a |
| 22 | 22nd | 22e | 22.º | 22:a |
| 111 | 111th | 111e | 111.º | 111:e |
The test needs no widget tree, because gen-l10n emits a top-level lookupAppLocalizations function:
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/ordinal_category.dart';
void main() {
const cases = <String, Map<int, String>>{
'en': {1: '1st', 2: '2nd', 3: '3rd', 11: '11th', 12: '12th',
21: '21st', 22: '22nd', 111: '111th'},
'fr': {1: '1er', 2: '2e', 11: '11e', 21: '21e'},
'es': {1: '1.º', 2: '2.º', 21: '21.º'},
'sv': {1: '1:a', 2: '2:a', 3: '3:e', 11: '11:e', 12: '12:e', 21: '21:a'},
};
cases.forEach((code, expected) {
test('ordinals for $code', () {
final l10n = lookupAppLocalizations(Locale(code));
expected.forEach((n, want) {
final got = l10n.ordinal(n, ordinalCategory(n, code), 'masculine');
expect(got, want, reason: '$code $n');
});
});
});
test('gender changes the French and Spanish form', () {
final fr = lookupAppLocalizations(const Locale('fr'));
final es = lookupAppLocalizations(const Locale('es'));
expect(fr.ordinal(1, 'one', 'feminine'), '1re');
expect(es.ordinal(1, 'other', 'feminine'), '1.ª');
});
}
The 11, 12 and 111 rows are the ones that catch a wrong rule. Keep them when you add a language.
Checklist
- Remove every
selectordinalfrom your ARB files. gen-l10n acceptsplural,select, anddateortimeformats only. - Do not rename it to
plural. Cardinal categories give "2th" in English. - Compute the ordinal category in Dart and feed it to a
select. - Keep
otherin every select. gen-l10n rejects a select without it. - Add a gender placeholder only where the noun is dynamic.
Keep ordinal messages consistent across locales
Ordinal selects are easy to get wrong by hand: a Swedish file that still carries the English two and few branches, or a French file missing the nested gender select. Reviewing them in raw JSON across ten locales is slow.
The FlutterLocalisation ARB editor lets you edit app_<locale>.arb translations side by side in a UI, so you can compare the same key in every language without scrolling through JSON files. It also validates ICU plural syntax and flags locales that are missing a plural category the language needs, which covers the cardinal plurals sitting next to your ordinals.
Try FlutterLocalisation free and manage your ARB files in one place.