Fix Flutter gen-l10n NumberFormat Constructor Error
You added a currency placeholder to one ARB message, ran flutter gen-l10n (or just hit build), and instead of generated Dart you got this:
Number format null for the number placeholder does not have a corresponding NumberFormat constructor.
The message that broke isn't the one you edited. It's a different message — one that has worked for months with a plain {count} int placeholder. Nothing about it changed. So why is it failing now?
This is one of the most confusing errors in Flutter i18n because the fix lives in a rule Flutter never states plainly. Once you know it, it takes 30 seconds to silence.
The non-obvious rule
Here it is, the whole thing in one sentence:
The moment any numeric placeholder in a message carries a
format(or is typednum/double), gen-l10n routes every numeric placeholder in that message — including bareints — through aNumberFormatconstructor. A placeholder with noformatresolves tonull, andnullis not a validNumberFormatconstructor. Hence the error.
When an int placeholder stands alone with no format, gen-l10n just interpolates it as $count — no NumberFormat involved, no problem. But add a formatted sibling (say a double amount with format: "currency"), and the whole message switches to the number-formatting code path. Now your untouched int needs an explicit format too, or gen-l10n has nothing to hand NumberFormat and emits the null you see above. This is tracked in flutter/flutter#115989, and it's working-as-designed enough that you should just give every numeric placeholder a format.
A message that reproduces it
This ARB compiles fine on its own:
{
"itemCount": "You have {count} items",
"@itemCount": {
"placeholders": {
"count": { "type": "int" }
}
}
}
Now you extend it to also show a total price:
{
"cartSummary": "{count} items for {total}",
"@cartSummary": {
"placeholders": {
"count": { "type": "int" },
"total": {
"type": "double",
"format": "currency"
}
}
}
}
Run flutter gen-l10n and it dies. total is fine — it has a format. count is the culprit: it's a numeric placeholder in a message that now uses NumberFormat, but it has no format, so the generator tries to build NumberFormat.null(...) and bails.
The fix: give the bare int a format
Add an explicit format to the int. For a plain integer you almost always want decimalPattern — it produces locale-aware digit grouping (1,234 in en-US, 1 234 in fr, 1.234 in de) and takes no extra parameters:
{
"cartSummary": "{count} items for {total}",
"@cartSummary": {
"placeholders": {
"count": {
"type": "int",
"format": "decimalPattern"
},
"total": {
"type": "double",
"format": "currency",
"optionalParameters": {
"symbol": "$",
"decimalDigits": 2
}
}
}
}
}
That's it. gen-l10n now has a real constructor (NumberFormat.decimalPattern) for count, and the build passes. The generated Dart looks roughly like:
String cartSummary(int count, double total) {
final countString = NumberFormat.decimalPattern(localeName).format(count);
final totalString = NumberFormat.currency(
locale: localeName,
symbol: '\$',
decimalDigits: 2,
).format(total);
return '$countString items for $totalString';
}
Call it exactly as before — the signature keeps native Dart types:
Text(AppLocalizations.of(context)!.cartSummary(3, 19.99));
// en-US: "3 items for $19.99"
Which formats are actually valid
gen-l10n validates your format string against a fixed set that mirrors NumberFormat's factory constructors. Anything outside this set throws "does not have a corresponding NumberFormat constructor" — this time because the name, not null, is unknown. The valid names are:
compactcompactCurrencycompactSimpleCurrencycompactLongcurrencydecimalPatterndecimalPatternDigitsdecimalPercentPatternpercentPatternscientificPatternsimpleCurrency
Two gotchas worth knowing:
- Watch the spelling.
decimalPatternDigitshas historically been rejected or mishandled by some tool versions (#122785); for a plain int,decimalPatternis the safe, universally-supported choice. optionalParametersonly apply to the constructors that accept them.currency,compactCurrency, andsimpleCurrencytake named args likesymbol,name,decimalDigits, andcustomPattern. Put anything else there and you'll get a different, equally cryptic codegen failure.
A fully-specified currency placeholder using all the useful knobs:
"amount": {
"type": "double",
"format": "currency",
"optionalParameters": {
"name": "USD",
"symbol": "\$",
"decimalDigits": 2,
"customPattern": "\u00a4#0.00"
}
}
The mental model: never mix a bare int in
Don't think of it as "one message broke." Think of it as: within a single message, numeric placeholders are all-or-nothing. If one of them needs formatting, they all declare a format. It's cheap insurance — adding "format": "decimalPattern" to every int/num/double placeholder costs nothing and eliminates a whole class of build-time surprises, including the ones that only appear when a future edit introduces a formatted sibling.
For the full breakdown of every placeholder type — DateTime, String, num, custom types, and the ICU select/plural interactions — see our complete guide to Flutter ARB placeholders.
Catch it before CI does
The real pain isn't the fix — it's that raw ARB JSON hides the problem until flutter gen-l10n runs, often in CI, on a message you didn't touch. A missing format on an int is invisible when you're staring at braces and commas.
That's exactly what the FlutterLocalisation ARB editor is built to surface. You edit each message's placeholders in a structured UI — type and format are fields, not hand-typed JSON keys — so a numeric placeholder without a resolvable format is obvious while you're editing it, not three commits later when the pipeline goes red. Combined with its ICU plural-syntax validation (which flags locales missing a plural category the language actually needs), it keeps the whole class of gen-l10n codegen errors out of your build.
You can manage every locale of your app_<locale>.arb files in one place, and the free tier is enough to clean up an existing project. Try FlutterLocalisation free and stop debugging NumberFormat constructors in CI logs.