DEV Community

Cover image for One Flutter string, five ARB files, no check
Artem Hetman
Artem Hetman

Posted on Edited on Originally published at stringlane.app

One Flutter string, five ARB files, no check

Originally published on stringlane.app.

I am a Flutter Staff Engineer and I have been building mobile apps for ten years. Over that time I have worked on a dozen or so of them, mostly commercial, with a few of my own. One of mine supports five languages and I only understand two of them.

That was fine until I shipped a release with strings missing from some of those locales, and translations that were wrong where they existed at all. Every missing string rendered in English, and the build that produced them was green. No warning, no red. A user told me, after it shipped.

Everything else in that release ran itself. Codemagic builds the ipa and the aab, ships them to TestFlight and the Play Store internal track, and then emails me that it worked. Localization was the hole. That hole is fairly specific, and if you ship a Flutter app in more than one language you probably have the same one.

Adding one string means opening five files

Here is the whole problem, and it is not clever.

ARB is the translation file format Flutter reads: JSON, with per-key metadata attributes. gen_l10n wants one file per locale. I add a key to the English one:

{
  "checkoutButtonLabel": "Continue to checkout"
}
Enter fullscreen mode Exit fullscreen mode

Then I open app_uk.arb and add it. Then app_pl.arb. Then app_de.arb. Then app_es.arb. One string, five files, and nothing in that process checks whether I got all five.

This is the gen_l10n workflow specifically. If the chore is all you want gone, slang replaces it: one file per locale still, but dart run slang analyze reports missing and unused translations, and its migrate command converts ARB files into slang's own JSON, one file at a time. It is the answer this complaint usually gets, and it is a fair one.

After that, the translations. I paste the new strings into an AI, get them back, and paste each one into the right file. Three keys is three passes. Now I have model output in four languages, three of which I do not understand, and nothing has checked any of it.

The build will not save you here

You would expect a missing translation to stop the build. It does not.

Run flutter gen-l10n with a locale that is short a few keys and you get this, in among the rest of the build output:

"de": 12 untranslated message(s).
To see a detailed report, use the untranslated-messages-file
option in the l10n.yaml file:
untranslated-messages-file: desiredFileName.txt
<other option>: <other selection>

This will generate a JSON format file containing all messages that
need to be translated.
Enter fullscreen mode Exit fullscreen mode

It exits successfully and the build continues.

The usual version of this complaint is wrong, so it is worth being precise. Flutter reports the gap. It prints a count, it tells you how to get the detailed version, and if you take that advice it writes a JSON report listing every missing key per locale. The information is there.

Two details about how it reaches you. The count goes out through logger.printStatus, not printWarning and not printError, so it arrives at the same level as every other line in the build. And that call sits in an else branch: set untranslated-messages-file and the console count stops printing entirely, replaced by the file.

None of it stops the build. That was the part I had not expected, so I read what the generator does with a message that has no translation for a locale. It is in gen_l10n.dart:

var localeWithFallback = locale;
if (message.messages[locale] == null) {
  _addUnimplementedMessage(locale, message.resourceId);
  localeWithFallback = _templateArbLocale;
}
Enter fullscreen mode Exit fullscreen mode

The missing German string falls back to the template locale. It renders in English. The app compiles, runs, and looks fine to me, because I read English and I wrote the template.

That is the release I shipped.

A count is not a check

Say you do read the count, and you fix all twelve. You still know nothing about:

  • A string that exists and is wrong. Present in the file, so it is not untranslated.
  • A dropped placeholder. A translation comes back without {count} in it at all. The method still takes the parameter, the string never uses it, and gen_l10n does not report it. Rename it instead, {count} to {Anzahl}, and you get this: gen_l10n infers an undeclared placeholder, generates a method taking both names, and your call sites stop compiling.
  • Missing plural forms. English has two categories, one and other. Ukrainian has four, and one is not the one you would guess: it takes 21 and 31, but not 11. Hand a model an English plural and you get back two forms. The key is present, the count is satisfied, and every number that needed few or many falls through to the single other string.

So the count answers "did someone type something here." It does not answer "is this correct."

Some strings are not in ARB at all. Your app's display name and every iOS permission prompt come from ios/Runner/Info.plist, and localizing them means a file you create by hand: ios/Runner/<lang>.lproj/InfoPlist.strings, which is Apple's long-documented mechanism for localizing Info.plist keys. gen_l10n never opens it. So you can have a perfect ARB report and still ship a camera permission dialog in English.

The plural key cartItemCount in a five-locale Flutter project. Ukrainian came back with only one and other, so few and many are flagged as CLDR-required. Spanish carries one, many and other, and Polish all four.

Three things you can do today without any new tool

These all work. I used the first two for a while.

1. Turn on the report. Flutter told you how, in that block above. In l10n.yaml:

untranslated-messages-file: l10n_missing.json
Enter fullscreen mode Exit fullscreen mode

You get the file instead of the count. Catches absent keys. Catches nothing else. And it takes the console line away, so either you read the file, or you see nothing in the console.

2. Diff the keys yourself. ARB is JSON, so this is short:

import json, pathlib, sys

l10n = pathlib.Path("lib/l10n")
template = json.loads((l10n / "app_en.arb").read_text(encoding="utf-8"))
keys = {k for k in template if not k.startswith("@")}
incomplete = False

for f in sorted(l10n.glob("app_*.arb")):
    if f.name == "app_en.arb":
        continue
    other = json.loads(f.read_text(encoding="utf-8"))
    missing = keys - {k for k in other if not k.startswith("@")}
    if missing:
        incomplete = True
        print(f.name, sorted(missing))

sys.exit(1 if incomplete else 0)
Enter fullscreen mode Exit fullscreen mode

Run it before you tag. That encoding="utf-8" is load-bearing on Python 3.14 and earlier. Without it the script dies on the first Ukrainian file on a Windows runner, which took me longer to work out than it should have.

3. Fail the build in CI. Wire the report from step 1 into a check that fails when the file contains anything but {}. Watch that shape. When nothing is missing gen_l10n writes an empty JSON object, not an empty file, so [ -s l10n_missing.json ] is true on every green build and the check fires forever until you give up and delete it. jq -e 'length == 0' l10n_missing.json is the whole check.

And if a new tool is not the problem, dart pub global activate rebellion then rebellion analyze lib/l10n/. It catches missing keys, missing plural values per locale, and a translation that dropped a placeholder the template declares or added one it does not. That covers the placeholder and plural gaps above, and you write none of it. It is ARB only, it is CLI only by its author's own README, and the last release was March 2025.

If you would rather have this in the editor, two extensions cover part of it. Google's ARB Editor validates a file against the ARB schema, understands ICU syntax and offers quick fixes: 112,000 installs, last updated September 2026. i18n-ally shows every locale for a key at once and has passed a million installs, though its repository has had no commits since November 2024. Both work on the file you have open rather than sweeping a project on a tagged build.

The three do not have the same limits. Option 2 only compares key sets. Options 1 and 3 read gen_l10n's own report. And whichever you pick, gen_l10n runs on every build anyway, so malformed ICU fails the build for free.

If you ship ARB only and you understand every language in your app, that is enough. It stops being enough when the values themselves need checking, or when strings start living outside ARB: the InfoPlist.strings above, a marketing site, a web dashboard sharing the same copy.

One check over all five ARB files

I wrote StringLane because localization was the one part of my release I still did by hand. It is a desktop app, which is the part I sell, and a command-line tool, which is free. This section is the free part, run on a project small enough to read.

Five locales, four keys. The English template:

{
  "@@locale": "en",
  "appTitle": "Groceries",
  "checkoutButtonLabel": "Continue to checkout",
  "welcomeBack": "Welcome back, {name}",
  "@welcomeBack": {
    "placeholders": {
      "name": { "type": "String" }
    }
  },
  "cartItemCount": "{count, plural, one {{count} item in your cart} other {{count} items in your cart}}",
  "@cartItemCount": {
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Polish is complete. Each of the other three carries one of the gaps from earlier:

  • app_de.arb has no checkoutButtonLabel.
  • app_es.arb has "welcomeBack": "Hola de nuevo". The {name} is gone. Its plural has all three Spanish forms, including many, which CLDR keeps for round millions.
  • app_uk.arb has cartItemCount with one and other, and nothing else.

On Flutter 3.47.0, flutter gen-l10n prints "de": 1 untranslated message(s). and exits 0. flutter analyze finds no issues. Neither one mentions Spanish or Ukrainian.

The check needs Node 22 or newer, which a Flutter pipeline may not have installed yet. npx fetches the CLI, and @latest makes it ask npm for the current release instead of running an older copy you may already have installed. The output below is from 0.6.1.

npx @stringlane/cli@latest check .; echo $?
Enter fullscreen mode Exit fullscreen mode
flutter · arb · single-origin · base en (detected)
5 locales · 4 keys · 90% complete

  de      4/5        1 missing
  en      5/5        complete
  es      6/6        complete
  pl      7/7        complete
  uk      5/7        2 missing

4 errors · 0 warnings · 0 info
  error    missing_key           de/checkoutButtonLabel: Key "checkoutButtonLabel" exists in base locale but is missing in "de"
  error    placeholder_mismatch  es/welcomeBack: Placeholder mismatch in "welcomeBack" for "es" — missing: name
  error    missing_key           uk/cartItemCount[few]: Plural quantity "few" for "cartItemCount" is missing in "uk"
  error    missing_key           uk/cartItemCount[many]: Plural quantity "many" for "cartItemCount" is missing in "uk"
1
Enter fullscreen mode Exit fullscreen mode

The table in that output counts cells, not keys. A plural key is one cell per category the locale needs, which is why Polish has seven and German five. The Ukrainian plural is two errors, one for each category it is missing.

Spanish reads as complete and still has an error: complete means every cell has a value, and says nothing about what is in it.

Now the fix. Add the German key, put {name} back into the Spanish string, and give Ukrainian its two missing forms:

{
  "@@locale": "uk",
  "appTitle": "Покупки",
  "checkoutButtonLabel": "Перейти до оформлення",
  "welcomeBack": "З поверненням, {name}",
  "cartItemCount": "{count, plural, one {{count} товар у кошику} few {{count} товари у кошику} many {{count} товарів у кошику} other {{count} товару у кошику}}"
}
Enter fullscreen mode Exit fullscreen mode

Same command:

flutter · arb · single-origin · base en (detected)
5 locales · 4 keys · 100% complete

  de      5/5        complete
  en      5/5        complete
  es      6/6        complete
  pl      7/7        complete
  uk      7/7        complete

0 errors · 0 warnings · 0 info
0
Enter fullscreen mode Exit fullscreen mode

In CI, the exit code is what fails the job: 0 clean, 1 issues found, 2 the check could not run. A project that is not clean today can record a baseline and fail only on new problems, and --json writes the findings as a report. The CI guide has a job to copy.

check writes nothing into your repository. What it keeps between runs lives in StringLane's own state directory: a cache of the source files it has scanned, a random install ID, and when it last checked npm for a newer version. The first run prints a notice on stderr saying the CLI collects usage data under that ID and never sends your file paths, translation keys, translated text or API keys. STRINGLANE_TELEMETRY=0 or DO_NOT_TRACK=1 turns it off, and a CI runner sends nothing. Telemetry was off for the runs above, so the notice is not in the pasted output.

rebellion, named above, finds the same four problems in these five files, and for ARB alone it is a complete answer. Where they part is the rest of a Flutter app's strings. One check run reads one format, and pointed at the project root it reads ARB, so the InfoPlist.strings from earlier needs a run of its own:

npx @stringlane/cli@latest check ios/Runner
Enter fullscreen mode Exit fullscreen mode

With NSCameraUsageDescription in en.lproj and not in de.lproj, that run reports a missing_key error for German and exits 1. The same command reads .xcstrings, Android XML and i18next JSON projects, if some of your copy lives there.

check does not translate anything, and it does not know whether a translation is good, which is the other half of what got me. What it removes is the other class of failure, the one where you could have known and did not.

If you run the check on your own project, I would genuinely like to hear where it breaks. And feature requests, obviously.

Top comments (0)