← Back to Blog

Flutter ARB Apostrophes: Fix {name} Printed Literally

fluttergen-l10narbicui18nfrench

Flutter ARB Apostrophes: Fix {name} Printed Literally

You add app_fr.arb, run the app in French, and the welcome screen says Bienvenue sur lapplication {appName}, cest parti !. The apostrophes are gone and the placeholder is printed as raw text. Or the build stops with:

ICU Lexing Error: Unmatched single quotes.
    Bienvenue sur l'application {appName}
                  ^

English was fine. Nothing changed in Dart. The only difference is that French, Italian, Portuguese and Catalan use the apostrophe constantly (l', d', c'è, qu'il), and gen-l10n has one setting that decides whether an apostrophe is a letter or a syntax character.

The ICU rule nobody tells you

ARB messages are parsed as ICU MessageFormat. In ICU, a single quote is the escape character: anything between a pair of single quotes is copied literally, braces included, and '' means one real apostrophe.

Flutter's gen-l10n does not apply this rule by default. The lexer in message_parser.dart has two modes, controlled by use-escaping in l10n.yaml:

use-escaping Apostrophe ' { and }
false (default) ordinary character, never special always syntax, cannot be written literally
true starts and ends a literal run; '' is one apostrophe syntax outside quotes, literal inside '{'

So the failure only appears when use-escaping: true is on, usually because someone needed a literal { in an English string months ago. From then on every French apostrophe is parsed as a quote delimiter.

What actually happens to your French string

Take this translation in app_fr.arb with escaping enabled:

{
  "welcome": "Bienvenue sur l'application {appName}, c'est parti !",
  "@welcome": {
    "placeholders": { "appName": { "type": "String" } }
  }
}

The lexer tries the quoted-literal pattern '[^']*' first. It matches 'application {appName}, c' as one literal token. The quotes are stripped, the text inside is kept verbatim, and {appName} is never recognised as a placeholder.

ARB message Rendered (use-escaping: true)
Bienvenue sur l'application {appName}, c'est parti ! Bienvenue sur lapplication {appName}, cest parti !
Bienvenue sur l'application {appName} build error: Unmatched single quotes

Two apostrophes give you the silent version: the string renders, but with the placeholder not replaced and the apostrophes swallowed. One apostrophe, or any odd count, gives the loud version: flutter gen-l10n throws ICU Lexing Error: Unmatched single quotes. and the generated file is not updated. The loud one is the lucky case. The silent one ships to the store.

This is also why the English template never showed the problem. Welcome to {appName} has no apostrophe. The moment an English string contains one, such as Let's go, it fails generation the same way with escaping on, so the bug is about the setting, never about the language.

Fix 1: leave use-escaping off and keep apostrophes plain

If no message in any locale needs a literal { or }, do not enable escaping. Check l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
# use-escaping: true   <- remove or set to false

With the default, l'application {appName} lexes as the text l'application , a placeholder, and nothing else. Translators write natural French and nothing needs doubling. Run flutter gen-l10n and confirm the generated app_localizations_fr.dart contains the apostrophe inside the Dart string.

This is the right fix for most apps. You lose only one thing: the ability to put a literal brace in a message.

Fix 2: keep use-escaping on and double every apostrophe

If you need literal braces, say for a template hint like Use {count} in your message, you have to keep escaping and then obey ICU everywhere. l10n.yaml:

use-escaping: true

Every apostrophe in every ARB file, including the English template, becomes ''. The French file above becomes:

{
  "welcome": "Bienvenue sur l''application {appName}, c''est parti !",
  "@welcome": {
    "placeholders": { "appName": { "type": "String" } }
  },
  "templateHint": "Utilisez '{count}' dans votre message"
}

Rendered output:

Bienvenue sur l'application Atlas, c'est parti !
Utilisez {count} dans votre message

The escaping behaviour, exactly as the lexer implements it:

  • '' anywhere produces one apostrophe.
  • '...' produces the inner text verbatim, so '{' and '}' are the only way to print a brace.
  • Inside a quoted run you can still write '' for an apostrophe. The official docs example Hello! '{Isn''t}' this a wonderful day? renders as Hello! {Isn't} this a wonderful day?.
  • An odd number of single quotes in a message is a hard error, so a missed '' fails generation rather than rendering wrong.

The cost is discipline. One translator typing a natural c'est in a 400-key file breaks the build or, if it pairs with another apostrophe in the same message, silently prints {name}. Italian (c'è, un'ora), Portuguese (d'água) and Catalan (l'usuari) hit this in almost every sentence, so a UI editor that shows the rendered string helps more than a JSON diff. The FlutterLocalisation ARB editor edits app_<locale>.arb as fields instead of raw JSON, which makes a dropped '' obvious before it reaches gen-l10n.

Picking between the two

Use Fix 1 unless you can name a message that needs a literal brace. If you can, use Fix 2 and add the test below so CI, not a user in Lyon, finds the next missed ''. Do not mix: use-escaping is global, so a doubled '' in a project with escaping off renders as two apostrophes.

If you only need a stray brace in a handful of strings and cannot afford to retouch every apostrophe, the relax-syntax: true option is a middle ground. It treats a { not followed by a known placeholder, and a } that closes nothing, as plain text. It does not change apostrophe handling at all.

A Dart test that catches the regression

This test renders the French string and asserts the value was substituted and the apostrophe survived. It runs in flutter test without a widget tree, using the lookupAppLocalizations function that gen-l10n emits in app_localizations.dart.

// test/l10n_apostrophe_test.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';

void main() {
  test('French welcome keeps apostrophes and replaces the placeholder', () {
    final fr = lookupAppLocalizations(const Locale('fr'));
    final rendered = fr.welcome('Atlas');

    expect(rendered, contains('Atlas'));
    expect(rendered, isNot(contains('{appName}')));
    expect(rendered, contains("l'application"));
    expect(rendered, "Bienvenue sur l'application Atlas, c'est parti !");
  });

  test('no locale leaks a raw placeholder', () {
    for (final locale in AppLocalizations.supportedLocales) {
      final l10n = lookupAppLocalizations(locale);
      expect(l10n.welcome('Atlas'), isNot(matches(r'\{\w+\}')),
          reason: 'welcome in $locale printed a literal placeholder');
    }
  });
}

Adjust the import path to your output-dir. The second test is the cheap, broad one: it loops every supported locale and fails on any {word} left in the output, which is exactly the symptom of a paired-apostrophe literal run. Pair it with the approach in Fail CI when a Flutter locale is missing translations and the build now fails for both missing and mangled strings.

Where else the same rule bites

The apostrophe rule applies inside plural and select branches too, since they are parsed by the same lexer. A French plural like {count, plural, =1{un objet} other{{count} objets}} is safe, but other{{count} d'objets} with escaping on starts a quoted run at d'. If you are debugging a select message that already errors, read Fix ICU Syntax Error for gender select first, and for few/many category problems see Flutter plurals for Arabic, Polish and Russian.

Checklist

  1. Open l10n.yaml and note whether use-escaping is true.
  2. If true and you have no literal braces, remove it and regenerate.
  3. If you keep it, replace every ' with '' in all ARB files, including English, and quote braces as '{'.
  4. Add the locale loop test above so a raw {name} fails flutter test.
  5. Edit translations in a tool that shows the rendered string, so a single missed quote is visible before commit.

FlutterLocalisation's ARB editor manages every app_<locale>.arb in one place and validates ICU plural categories per language, so the French, Italian and Catalan files stay consistent as the key count grows. See pricing for the free tier, or try FlutterLocalisation free and import your ARB files today.