Flutter 3.29 gen-l10n Placeholder Type Mismatch: 3 Fixes
You upgraded the Flutter SDK, ran the app, and flutter gen-l10n stopped with this:
The placeholder, count, has its "type" resource attribute set to the "null" type
in locale "de", but it is "num" in the template placeholder. For compatibility
with template placeholder, change the "type" attribute to "num".
Nobody on the team set a type of null anywhere, and the same ARB files built fine on 3.27. This is the flutter gen-l10n placeholder type mismatch tracked as flutter/flutter#163627. It has an official fix, but there is also a second version of the error that the fix does not remove. This post covers both.
What actually changed in Flutter 3.29
Before 3.29, the placeholders block under an @key only mattered in the template file (usually app_en.arb). Whatever metadata sat in app_de.arb or app_ja.arb was ignored.
From 3.29, gen-l10n also reads the placeholders metadata in translated files and builds a separate placeholder object per locale. It then compares each one to the template and throws if the types differ. That comparison is where the error above comes from.
The bug: the generator infers a type when you do not declare one (num for a placeholder used in a plural, String for a select, Object otherwise), but in 3.29.0 to 3.29.2 that inference only ran over the template's placeholders. The per-locale ones were left with no type at all.
The minimal reproduction
This is the repro attached to the issue. Both files are valid and say the same thing.
lib/l10n/app_en.arb:
{
"helloWorld": "{count, plural, one{Hello World!} other{Hello Worlds!}}",
"@helloWorld": {
"description": "The conventional newborn programmer greeting",
"placeholders": {
"count": {}
}
}
}
lib/l10n/app_de.arb:
{
"helloWorld": "{count, plural, one{Hallo Welt!} other{Hallo Welten!}}",
"@helloWorld": {
"description": "The conventional newborn programmer greeting",
"placeholders": {
"count": {}
}
}
}
The template's count is used in a plural, so it is inferred as num. The German count is also used in a plural, but on 3.29.0 to 3.29.2 nothing assigns it a type, so it stays null. null is not num, and the build fails.
Two conditions have to be true for you to hit it:
- A translated ARB file repeats the
placeholdersblock for a key. Many translation tools and copy-paste workflows export the full@keymetadata into every locale, which is why this hit so many projects at once. - The placeholder has no explicit
typein that translated file.
If your translated files contain only keys and strings, with no @key entries, you never see this error.
Fix 1: upgrade to 3.29.3 or later
The fix landed on master in PR #163690 and was cherry-picked to stable in 3.29.3. The changelog entry reads: "Fix issue where placeholder types in ARB localizations weren't used for type inference, causing a possible type mismatch with the placeholder field defined in the template." Every later stable release includes it.
flutter upgrade
flutter --version
flutter gen-l10n
If you pin the SDK with FVM or in CI, move the pin to 3.29.3 or newer. With the fix, the repro above generates String helloWorld(num count) with no ARB changes.
This is the least effort and it is the right first step. It does not cover every case though.
Fix 2: declare explicit types, and keep them identical
Upgrading makes inference run on both sides. It does not remove the comparison. So you can still get a mismatch on a current SDK in two situations.
The template declares a type and the translation does not:
"@greet": { "placeholders": { "name": { "type": "String" } } }
"@greet": { "placeholders": { "name": {} } }
The translated name is used as plain text, so it is inferred as Object, which is not String.
Or the translation uses the placeholder differently from the template. Japanese has no plural forms, so a translator reasonably writes a plain string:
"itemCount": "{count}件",
"@itemCount": { "placeholders": { "count": {} } }
The template's count is a plural (num), the Japanese one is plain (Object). On a current stable SDK this still fails:
For the message "itemCount" the placeholder "count" has its "type" resource
attribute set to the type "Object" in locale "ja", but it is "num" in the
template placeholder.
The rule that makes all of this go away: declare the type in the template, and in any translated file that repeats the placeholder, declare the same type.
"@itemCount": {
"placeholders": {
"count": { "type": "int" }
}
}
Plural placeholders must be num or int, select placeholders must be String. An explicit type also gives you a tighter generated signature (int count instead of num count). There is more on that in our post on plurals with placeholders in gen-l10n.
The other valid option is to delete the placeholders block from the translated file. A locale with no metadata for a key simply uses the template's. Only keep the block in a translation when you need it, for example a different date or number format for that locale.
Fix 3: a Dart script that finds (and repairs) every mismatch
With ten locales and a few hundred keys, checking this by hand is not realistic. This script walks every non-template ARB, works out the effective type of each redeclared placeholder (the declared type, or the type implied by plural, select or plain usage), and compares it with the template.
Save it as tool/check_arb_placeholders.dart:
// Usage: dart run tool/check_arb_placeholders.dart [arb-dir] [template-file] [--fix]
import 'dart:convert';
import 'dart:io';
String usageOf(String message, String name) {
final match = RegExp('\\{\\s*$name\\s*,\\s*(plural|select)\\s*,').firstMatch(message);
return match?.group(1) ?? 'plain';
}
String inferredType(String usage) => switch (usage) {
'plural' => 'num',
'select' => 'String',
_ => 'Object',
};
Map<String, dynamic> placeholdersOf(Map<String, dynamic> arb, String key) {
final meta = arb['@$key'];
if (meta is! Map<String, dynamic>) return {};
final placeholders = meta['placeholders'];
return placeholders is Map<String, dynamic> ? placeholders : {};
}
void main(List<String> args) {
final fix = args.contains('--fix');
final positional = args.where((a) => !a.startsWith('--')).toList();
final dir = Directory(positional.isNotEmpty ? positional[0] : 'lib/l10n');
final templateName = positional.length > 1 ? positional[1] : 'app_en.arb';
final templateFile = File('${dir.path}/$templateName');
final template = jsonDecode(templateFile.readAsStringSync()) as Map<String, dynamic>;
var problems = 0;
final files = dir.listSync().whereType<File>().where(
(f) => f.path.endsWith('.arb') && !f.path.endsWith('/$templateName'),
);
for (final file in files) {
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
var changed = false;
for (final key in arb.keys.where((k) => !k.startsWith('@'))) {
final templateMessage = template[key];
final message = arb[key];
if (templateMessage is! String || message is! String) continue;
final templatePlaceholders = placeholdersOf(template, key);
final localePlaceholders = placeholdersOf(arb, key);
for (final name in localePlaceholders.keys) {
final templateUsage = usageOf(templateMessage, name);
final localeUsage = usageOf(message, name);
final templateDecl = templatePlaceholders[name];
final localeDecl = localePlaceholders[name];
final templateType =
(templateDecl is Map ? templateDecl['type'] as String? : null) ??
inferredType(templateUsage);
final localeType =
(localeDecl is Map ? localeDecl['type'] as String? : null) ??
inferredType(localeUsage);
if (templateType == localeType) continue;
problems++;
stdout.writeln(
'${file.uri.pathSegments.last}: $key.$name is $localeType '
'($localeUsage), template says $templateType ($templateUsage)',
);
if (fix && localeDecl is Map<String, dynamic>) {
localeDecl['type'] = templateType;
changed = true;
}
}
}
if (changed) {
file.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(arb)}\n');
}
}
stdout.writeln(problems == 0 ? 'No placeholder type mismatches.' : '$problems mismatch(es).');
if (problems > 0 && !fix) exitCode = 1;
}
Run it from the project root:
dart run tool/check_arb_placeholders.dart
app_de.arb: greet.name is Object (plain), template says String (plain)
app_ja.arb: itemCount.count is Object (plain), template says num (plural)
2 mismatch(es).
Add --fix and it writes the template's type into each mismatched placeholder in the translated files, so you do not touch them by hand:
dart run tool/check_arb_placeholders.dart --fix
flutter gen-l10n
A few things to know before you run it:
--fixrewrites the changed files with two-space indentation. Commit first so the diff is easy to review.- It exits with code 1 when it finds mismatches without
--fix, so it works as a CI step ahead offlutter gen-l10n. - Pass a different folder or template as arguments:
dart run tool/check_arb_placeholders.dart lib/l10n app_en.arb. - It only looks at plural, select and plain usage. It will not catch the separate "Placeholder is used as plural/select/datetime in certain languages" error, where one placeholder is used in two different ways.
If gen-l10n now exits clean but nothing changes
Once the type error is gone, some projects find that flutter gen-l10n runs without output and the generated files do not update. That is a different problem, usually generate: true or the l10n.yaml paths. Our walkthrough of gen-l10n running but generating nothing covers it.
Quick summary
- The
nullvsnumerror after upgrading to 3.29 is a tooling bug (issue 163627), fixed in 3.29.3 and every later stable release. - A mismatch that survives the upgrade is real: the translated file redeclares a placeholder whose type, declared or inferred, differs from the template.
- Declare types in the template, keep any redeclared placeholder identical, or drop the
placeholdersblock from translations that do not need it. - Use the script to find and repair every case in one pass.
Try FlutterLocalisation free
Most of these mismatches start with hand-edited JSON spread across a dozen locale files. FlutterLocalisation gives you an ARB editor for your app_<locale>.arb files, so you manage translations across all your locales in a UI instead of raw JSON, and its ICU plural-syntax validation flags locales missing a plural category the language actually needs. There is a free tier, and the paid plans are on the pricing page.