← Back to Blog

Flutter ARB: Escape Braces Without Eating Apostrophes

flutterarbicul10ngen-l10n

Flutter ARB: Escape Braces Without Eating Apostrophes

You added one string containing a literal { } and flutter gen-l10n stopped building:

[app_en.arb:test] ICU Syntax Error: Expected "identifier" but found "}".
    You shouldn't use those characters: { } in this field
                                          ^
Found syntax errors.

Every answer you'll find says the same thing: turn on use-escaping. That does fix it. It also, in the same build, quietly rewrites l'application to lapplication in every French, Italian and Catalan string you already shipped — with no error, no warning, and no diff in your ARB files.

These are the two halves nobody connects. Here's both, with the exact escape rules and a migration checklist.

Why literal braces break the build

Flutter's gen-l10n runs your message through an ICU MessageFormat parser. In ICU, { opens a placeholder, plural or select block. The parser sees { }, expects an identifier after the brace, finds } instead, and aborts. It does not care that you declared no placeholders for that message — the brace is structural, not contextual.

This bit a lot of people upgrading from Flutter 3.3 to 3.7, when the hand-rolled parser was replaced by a real ICU lexer (flutter#122404). Strings that shipped fine for a year suddenly failed to parse. The parser has only gotten stricter since.

It hits anything with braces: JSON samples in a developer-tools app, {} in a code-teaching app, set notation like {1, 2, 3}, CSS or Handlebars snippets in help text, and math.

Fix 1: use-escaping + single quotes

In l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-dir: lib/l10n/generated
synthetic-package: false
use-escaping: true

(synthetic-package: false plus generate: true in pubspec.yaml is the post-3.32 layout — Flutter stopped emitting package:flutter_gen by default in the 3.32 stable line.)

With use-escaping: true, anything wrapped in a pair of single quotes is passed through as literal text, and the quotes themselves are removed:

{
  "setExample": "The set '{1, 2, 3}' has three members.",
  "emptyJson": "An empty object is '{}'.",
  "greeting": "Hello {name}! Braces look like '{' and '}'.",
  "@greeting": {
    "placeholders": { "name": { "type": "String" } }
  }
}

Output:

AppLocalizations.of(context)!.setExample;
// "The set {1, 2, 3} has three members."

AppLocalizations.of(context)!.greeting('Marie');
// "Hello Marie! Braces look like { and }."

Note greeting: real placeholders still work. Escaping is per-region, not per-message — {name} outside quotes is still interpolated.

Fix 2: the '' rule for a literal apostrophe

Once escaping is on, ' is a metacharacter. To emit one literal apostrophe you write two:

{
  "wonderful": "Hello! '{Isn''t}' this a wonderful day?"
}

Hello! {Isn't} this a wonderful day?

That is the documented example from the Flutter docs, and it's the whole rule. The problem is what it implies for the locales you already have.

The half nobody warns you about

use-escaping is a single global flag. There is no per-message or per-locale opt-out. The moment you flip it to fix one English string, every message in every ARB file is re-lexed under the new rules.

Look at what the lexer actually matches. With escaping enabled it scans for '[^']*' — a quote, any run of non-quotes, a closing quote — and emits the interior without the quotes. Now feed it real French:

{ "welcome": "C'est l'application la plus rapide." }

The lexer matches 'est l' as an escaped region. It strips both quotes and keeps the middle. You ship:

Cest lapplication la plus rapide.

No error. The build is green. The string just lost two characters, in production, in a locale you can't read.

Italian dell'utente, un'immagine, l'ordine; French aujourd'hui, qu'est-ce, n'a pas; Catalan l'usuari; English don't paired with a later it's — all vulnerable. Any two apostrophes in one message get treated as a delimiter pair, and everything between them loses its quotes.

An odd number is louder — that one does fail the build:

ICU Lexing Error: Unmatched single quotes.

So the damage pattern is perverse: a string with one apostrophe stops your build, and a string with two corrupts silently. French ARB files average well over one apostrophe per message, so most teams get a wave of lexing errors and a tail of silent corruption in the same commit.

Fix 3: relax-syntax — often the better trade

If your only problem is stray braces, there's a second flag that costs you nothing in apostrophes:

relax-syntax: true

This tells the lexer to treat unmatched { and } as literal strings: a } at nesting depth 0 becomes text, and a { followed by something that isn't a declared placeholder name becomes text. Apostrophes stay ordinary characters.

The catch: it is a relaxation, not an escape mechanism. A balanced {userName} inside a code sample will still be read as a placeholder if userName is declared on that message, and you get less protection against genuinely malformed ICU — a typo in a plural block may now parse as prose instead of erroring. Use it when your braces are incidental; use use-escaping when you need exact, deliberate control.

Fix 4: don't use a straight apostrophe at all

For French, Italian, Spanish and Catalan the typographically correct apostrophe is U+2019 (’), not the ASCII '. l’application is what a French typographer would set anyway — and U+2019 is not an ICU metacharacter, so it is completely immune to use-escaping.

Migrating your Romance-language ARB files to is the one change that fixes the typography and the escaping problem at the same time. Keep ASCII ' only where it's genuinely code.

Migration checklist

Before you flip use-escaping: true, audit every locale — not just the one that broke.

1. Find every at-risk message:

# any ASCII apostrophe in any ARB value, across all locales
grep -rn "'" lib/l10n/*.arb

2. Count them per file, so you know the blast radius:

import json, glob, re

for path in sorted(glob.glob('lib/l10n/*.arb')):
    data = json.load(open(path, encoding='utf-8'))
    hits = {
        k: v for k, v in data.items()
        if not k.startswith('@') and isinstance(v, str) and "'" in v
    }
    print(f'{path}: {len(hits)} message(s) with an ASCII apostrophe')
    for k, v in hits.items():
        n = v.count("'")
        flag = 'BUILD ERROR (odd)' if n % 2 else 'SILENT CORRUPTION (even)'
        print(f'  {k}: {n} -> {flag}\n    {v}')

3. Fix each hit by either doubling it (n''a pas) or, preferably for fr/it/es/ca, replacing it with .

4. Re-run generation and read the output, don't just check the exit code:

flutter gen-l10n
grep -n "apostrophe-bearing string" lib/l10n/generated/app_localizations_fr.dart

5. Lock it in. Add step 2 as a CI check so a translator pasting a raw ' into a French string can't merge silent corruption.

6. Tell your translators. If strings come back from a TMS or a freelancer, '' is now a rule they have to know. This is the real cost of use-escaping, and it's why relax-syntax is worth considering first.

Which flag to pick

  • Stray or unmatched braces in prose, no deliberate ICU tricks → relax-syntax: true. Cheapest, zero apostrophe risk.
  • Code samples, JSON, templates, math — you need exact literal control → use-escaping: true plus the full apostrophe migration above.
  • Romance-language apps → migrate to regardless of which flag you choose.

Catch this before it ships

The reason this class of bug survives to production is that ARB files are raw JSON reviewed in a diff, and a missing apostrophe in a language you don't speak is invisible there.

FlutterLocalisation gives you an ARB editor for your app_<locale>.arb files — you edit and review translations in a UI instead of hand-patching JSON across a dozen locales, which is exactly where '' mistakes and stray braces hide. It also runs ICU plural-syntax validation, flagging locales that are missing a plural category the language actually requires — a dropped few/many for Polish, Russian or Arabic — so malformed ICU is caught at edit time rather than at gen-l10n time.

Try FlutterLocalisation free and manage your ARB files somewhere the escaping rules are visible.

More on the config file itself: Flutter l10n.yaml: The Complete Configuration Guide.