← Back to Blog

Flutter gen-l10n: Look Up ARB Translations by Dynamic Key

flutteri18nl10ngen-l10narbdart

Flutter gen-l10n: Look Up ARB Translations by Dynamic Key

Your backend sends error_code: "insufficient_funds". You want to show the user the localized message for it. So you write:

final appLoc = AppLocalizations.of(context)!;
final message = appLoc[serverErrorCode]; // ❌ does not compile

And Dart rejects it: The operator '[]' isn't defined for the type 'AppLocalizations'.

This is the wall almost everyone hits once translation keys stop being hard-coded and start coming from an API, an error code, an enum, or a config file. Flutter's gen-l10n tool turns each ARB entry into a getter or methodappLoc.insufficientFunds — not into a Map<String, String> you can index by a variable. There is no runtime lookup table, and Flutter can't use dart:mirrors (reflection is stripped for tree-shaking), so you can't reflect over the class either.

Here are three copy-paste solutions, from smallest to most robust, with the trade-offs of each.

Why appLoc[key] can't work out of the box

Given app_en.arb:

{
  "insufficientFunds": "You don't have enough balance.",
  "cardDeclined": "Your card was declined.",
  "@insufficientFunds": { "description": "Shown on payment failure" }
}

flutter gen-l10n generates roughly:

class AppLocalizations {
  String get insufficientFunds => 'You don\'t have enough balance.';
  String get cardDeclined => 'Your card was declined.';
}

Statically typed getters — great for compile-time safety, useless when the key is a String in a variable. There's no operator [], no keys list, no translate(key). That's by design, and it's exactly what the still-open proposal flutter/flutter#105672 ("Access l10n Translations with Dynamic Keys") asks the team to change. Until it lands, you bridge the gap yourself.

Solution 1: A hand-rolled Map extension (smallest, zero deps)

Dart extensions can define an operator [], so you can actually make appLoc['insufficientFunds'] compile by mapping keys to the generated getters yourself.

// app_localizations_dynamic.dart
import 'app_localizations.dart';

extension AppLocalizationsDynamic on AppLocalizations {
  String? lookup(String key) {
    switch (key) {
      case 'insufficientFunds':
        return insufficientFunds;
      case 'cardDeclined':
        return cardDeclined;
      default:
        return null; // unknown key
    }
  }

  // Optional: enables appLoc['insufficientFunds'] syntax.
  String operator [](String key) => lookup(key) ?? key;
}

Usage:

final appLoc = AppLocalizations.of(context)!;
Text(appLoc.lookup(serverErrorCode) ?? appLoc.somethingWentWrong);
// or
Text(appLoc[serverErrorCode]);

Why it's nice: no packages, no build step, fully type-checked, and every getter stays statically referenced so the analyzer catches renames.

The trade-off: it's a manual map. Every new ARB key means another case. It will drift from your .arb files, and drift is silent — a missing case just returns null. Fine for a handful of stable keys (error codes, a fixed set of notification types); painful past a few dozen.

Solution 2: Reflection-free codegen (scales, stays in sync)

Since Flutter has no runtime reflection, the correct way to scale is to generate the lookup map from your ARB files at build time. You can hand-write a tiny script, or use the community package l10n_mapper_generator, which is built for exactly this: it reads the class gen-l10n already produced and emits a parseL10n lookup on top of it.

Add the dependencies and run both generators in order — gen-l10n first, then build_runner:

# pubspec.yaml
dev_dependencies:
  build_runner: ^2.4.0
  l10n_mapper_generator: ^0.0.4
flutter gen-l10n
dart run build_runner build --delete-conflicting-outputs

This emits app_localizations.mapper.dart with a BuildContext extension. Now the key can be any runtime string, and placeholder arguments are supported positionally:

// No arguments
final text = context.parseL10n('insufficientFunds');

// With placeholders, e.g. "minimumDeposit": "Deposit at least {amount} {currency}"
final text2 = context.parseL10n('minimumDeposit', arguments: [100, 'USD']);

Because the map is generated straight from the ARB, it can never silently drift — add a key, re-run the generators, and it's available by string. The generated lookup is also lazy-initialized and cached, so you're not rebuilding a map on every call.

The trade-off: you now own a build_runner step in CI and a third-party generator, and you lose compile-time safety on the string itself — context.parseL10n('insuficientFunds') (typo) compiles and fails at runtime. Always pass a fallback and treat unknown keys as data, not as a crash. If you'd rather not add a dependency, the same idea in ~30 lines of Dart that walks app_en.arb and writes a const Map<String, String Function(AppLocalizations)> works identically.

Solution 3: Keep dynamic keys in a managed ARB workflow

Solutions 1 and 2 answer "how do I read by key." The bigger question for server-driven strings is "how do I keep those keys translated across every locale without a human forgetting one." Dynamic keys are the easiest to leave half-translated, because the compiler never references them — gen-l10n's own untranslated-messages-file will flag a missing insufficientFunds in app_es.arb, but only if the key exists in your template.

The durable pattern:

  1. Treat the server contract as the source of truth. Every error_code, notification type, and enum value your backend can emit gets a matching ARB key. Namespace them (error.insufficientFunds, notif.paymentReceived) so they're easy to audit and don't collide with UI strings.
  2. Generate the lookup (Solution 2) so any of those keys resolves by string.
  3. Enforce completeness in translation, not in Dart. Turn on required-resource-attributes: true and point untranslated-messages-file at a file your CI reads, so a locale missing error.cardDeclined fails the build instead of shipping the raw key to a user.

Managing that key set across ten locales in raw JSON is where a dedicated editor pays off. FlutterLocalisation's ARB editor shows every key across every locale in one grid, flags the gaps a dynamic key would otherwise hide, and exports clean .arb your existing gen-l10n + build_runner pipeline consumes unchanged — no runtime SDK, no lock-in. See the full setup in our Flutter localization guide and the config reference in l10n.yaml explained.

The trade-off: more upfront discipline (you maintain a key contract), but it's the only approach that survives a growing product and a growing translator team.

Which one should you pick?

Approach Best for Cost
Hand-rolled Map extension A fixed, small set of keys (error codes, enums) Manual upkeep, silent drift
Reflection-free codegen Many keys, keys added often A build_runner step, no string-level safety
Managed ARB workflow Server-driven strings across many locales Upfront contract discipline

Most teams end up combining #2 and #3: generate the lookup so any key resolves by string, and manage the key set in a real editor so nothing ships untranslated. And keep an eye on #105672 — if native dynamic-key support ever lands in gen-l10n, Solution 2 becomes a one-line migration.

Try FlutterLocalisation free

Stop babysitting raw ARB JSON. Try FlutterLocalisation free — edit every key across every locale in one grid, catch the dynamic keys your compiler can't see, and export straight into your existing gen-l10n pipeline.