← Back to Blog

Flutter ARB Apostrophes: The use-escaping Trap

flutterarbl10nicuuse-escaping

Flutter ARB Apostrophes: The use-escaping Trap

Your French build just failed with ICU Lexing Error: Unmatched single quotes. The top answer says add use-escaping: true to l10n.yaml. It compiles. You ship. Two weeks later a user reports the app reads « Lami dAnna ».

That is not a typo. That is use-escaping doing exactly what it promises — to every string in every ARB file at once. use-escaping is a project-wide switch, not a per-message one, and the moment you flip it every existing apostrophe in app_fr.arb, app_it.arb, app_nl.arb and app_af.arb changes meaning.

What use-escaping actually does

When escaping is enabled, Flutter's ICU lexer in flutter_tools matches quoted spans with a single regular expression:

RegExp escapedString = RegExp(r"'[^']*'");

That is the entire rule: a ', any characters, another '. Note what it does not do — it never checks whether there is ICU syntax between the quotes. In the ICU MessageFormat spec proper, an apostrophe only begins a quoted section when it precedes {, } or #. Flutter's lexer is simpler and strips the pair regardless of what sits between them.

Three outcomes follow mechanically from how many ' characters a message contains:

  • Odd count → the last quote has no partner → ICU Lexing Error: Unmatched single quotes. The build fails. Loud, and therefore harmless.
  • Even count → every pair is stripped and its contents pass through as plain text. L'ami d'Anna becomes Lami dAnna. Silent. This is the one that ships to users.
  • '' → collapses into one literal '. This is the escape hatch you are supposed to be using.

So "flutter gen-l10n single quote removed" and "ICU Lexing Error: Unexpected character" are not two bugs. They are the same rule hitting an even or an odd number of apostrophes.

Decision table: literal quote vs. escape character

String in your ARB use-escaping off (default) use-escaping: true
d'accord (one ') renders d'accord build fails: unmatched single quotes
L'ami d'Anna (two ') renders L'ami d'Anna renders Lami dAnna — silent loss
L''ami renders L''ami (two visible quotes) renders L'ami
'n Boom (Afrikaans) renders 'n Boom build fails
{count} with a declared placeholder interpolates interpolates
'{count}' renders '5' — quotes literal, placeholder still fires renders the text {count}
a literal { or } ICU Lexing Error unless relax-syntax write '{' and '}'

The row that matters for French, Italian, Dutch and Afrikaans is row two. Dutch 's ochtends, Italian un'altra volta, Afrikaans 'n — these are everywhere, and half of them will pair up with an apostrophe several words away.

Copy-paste before/after pairs

Mode A — escaping off (the default)

Nothing is escaped. Apostrophes are ordinary characters. Braces are always syntax.

{
  "welcomeBack": "Content de vous revoir ! C'est aujourd'hui qu'on commence.",
  "itemDetails": "Détails de l'élément {value}",
  "@itemDetails": {
    "placeholders": { "value": { "type": "String" } }
  }
}

This builds and renders correctly today.

Mode B — escaping on

The same file, corrected. Every literal apostrophe is doubled:

{
  "welcomeBack": "Content de vous revoir ! C''est aujourd''hui qu''on commence.",
  "itemDetails": "Détails de l''élément {value}",
  "@itemDetails": {
    "placeholders": { "value": { "type": "String" } }
  }
}

If you forget welcomeBack, it has three apostrophes — odd — so the build fails and you find out. If you forget a two-apostrophe string, nothing fails and the text quietly loses characters. The dangerous strings are the ones with an even number of apostrophes.

Literal braces next to a real placeholder

This is the case most posts skip. Wrap the literal braces in single quotes; the declared placeholder outside the quotes still interpolates:

{
  "templateHint": "Tapez '{nom}' pour insérer le nom, puis saluez {userName}",
  "@templateHint": {
    "placeholders": { "userName": { "type": "String" } }
  }
}

Renders as Tapez {nom} pour insérer le nom, puis saluez Amélie. You can also quote each brace individually — '{'nom'}' — which is handy when the braces are far apart. Combine with a doubled apostrophe when you need both:

{
  "codeHint": "Tapez '{'code'}' pour l''activer"
}

The official docs example follows the same shape: "Hello! '{Isn''t}' this a wonderful day?" produces Hello! {Isn't} this a wonderful day?.

If you only need literal braces, don't touch use-escaping

Flutter has a second, narrower flag. relax-syntax treats { as a plain character when it isn't followed by a valid placeholder, and } as a plain character when it doesn't close anything. It was added in response to flutter/flutter#122404, where a 3.3 → 3.7 upgrade started rejecting strings like You shouldn't use those characters: { } in this field.

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
relax-syntax: true

Crucially, relax-syntax says nothing about apostrophes. Your French files keep working untouched. The trade-off is real, and the Flutter reviewers said so on the PR: relaxed lexing can mask genuinely malformed placeholder syntax by turning it into visible text instead of failing the build. Use it deliberately, not as a default.

Rule of thumb: literal braces only → relax-syntax. Braces and you want strict validation → use-escaping, and audit first.

The zero-flag option: use U+2019

Before reaching for either flag, consider that ' (U+0027) is a typewriter artefact. French, Italian, Dutch and Afrikaans typography all want (U+2019, RIGHT SINGLE QUOTATION MARK): aujourd’hui, un’altra, ’n boom. U+2019 is not ICU syntax under any flag, so it never lexes, never pairs, never disappears — and it renders better. For a lot of teams, a one-time sweep of U+0027 → U+2019 in the prose (not in code identifiers) removes the problem permanently.

Audit every ARB file before you flip the flag

This script simulates Flutter's lexer against your files and prints exactly which messages break and which change silently. No dependencies — drop it in tool/ and run it with dart run.

// tool/arb_quote_audit.dart
// Usage: dart run tool/arb_quote_audit.dart lib/l10n
import 'dart:convert';
import 'dart:io';

/// Mirrors the quoted-string token in Flutter's gen-l10n lexer
/// (flutter_tools/lib/src/localizations/message_parser.dart).
final RegExp _escapedString = RegExp(r"'[^']*'");

/// Returns what gen-l10n will emit for [message] once `use-escaping: true`
/// is set, or null if it would fail with "Unmatched single quotes."
String? renderUnderEscaping(String message) {
  final StringBuffer out = StringBuffer();
  int i = 0;
  while (i < message.length) {
    if (message[i] != "'") {
      out.write(message[i]);
      i++;
      continue;
    }
    final Match? match = _escapedString.matchAsPrefix(message, i);
    if (match == null) {
      return null; // A quote with no partner.
    }
    final String token = match.group(0)!;
    if (token == "''") {
      out.write("'"); // Doubled quote -> one literal apostrophe.
    } else if (i > 0 && message[i - 1] == "'") {
      out.write(token.substring(0, token.length - 1));
    } else {
      out.write(token.substring(1, token.length - 1));
    }
    i = match.end;
  }
  return out.toString();
}

void main(List<String> args) {
  final String dirPath = args.isEmpty ? 'lib/l10n' : args.first;
  final Directory dir = Directory(dirPath);
  if (!dir.existsSync()) {
    stderr.writeln('No such directory: $dirPath');
    exit(2);
  }

  final List<File> arbFiles = dir
      .listSync()
      .whereType<File>()
      .where((File f) => RegExp(r'app_[\w-]+\.arb$').hasMatch(f.path))
      .toList()
    ..sort((File a, File b) => a.path.compareTo(b.path));

  int broken = 0;
  int changed = 0;

  for (final File file in arbFiles) {
    final Map<String, dynamic> arb =
        jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
    for (final MapEntry<String, dynamic> entry in arb.entries) {
      if (entry.key.startsWith('@') || entry.value is! String) {
        continue;
      }
      final String value = entry.value as String;
      if (!value.contains("'")) {
        continue;
      }
      final String? rendered = renderUnderEscaping(value);
      if (rendered == null) {
        broken++;
        print('BUILD BREAK  ${file.path} :: ${entry.key}');
        print('  $value');
      } else if (rendered != value) {
        changed++;
        print('SILENT LOSS  ${file.path} :: ${entry.key}');
        print('  before: $value');
        print('  after:  $rendered');
      }
    }
  }

  print('\n${arbFiles.length} ARB files scanned — '
      '$broken would fail the build, $changed would change silently.');
  exit(broken + changed == 0 ? 0 : 1);
}

Run it against a real French file and the SILENT LOSS lines are your entire migration to-do list. Fix them by doubling the apostrophes (or converting to U+2019), re-run until the script exits 0, then add the flag.

Keep it in CI

Once use-escaping: true is committed, the script stops being a migration tool and becomes a regression guard — new translations arrive from translators who have never heard of doubled quotes.

# .github/workflows/l10n.yml
- uses: subosito/flutter-action@v2
  with: { channel: stable }
- run: dart run tool/arb_quote_audit.dart lib/l10n
- run: flutter gen-l10n

The safe rollout order

  1. Run the audit with the flag still off.
  2. Fix every BUILD BREAK and SILENT LOSS — double the apostrophes or switch to .
  3. Re-run until it exits 0.
  4. Add use-escaping: true to l10n.yaml.
  5. Run flutter gen-l10n and git diff the generated app_localizations*.dart files. On a clean migration, only the strings you deliberately escaped should differ.

Step 5 is the one people skip, and it is the only step that proves nothing vanished.

Catch this earlier than the build

Apostrophe damage is a data-integrity problem in your ARB files, not a Dart problem — which means the right place to catch it is where the translations are edited. The FlutterLocalisation ARB editor puts every locale's app_<locale>.arb side by side in a UI instead of raw JSON, so a French string that lost its apostrophes is visible next to the English source rather than buried in a diff. It also runs ICU plural-syntax validation, flagging locales that are missing a plural category their language actually needs — the same class of ICU mistake, caught before flutter gen-l10n ever runs.

More Flutter i18n walkthroughs are on the FlutterLocalisation blog, and pricing starts at a free tier.

Try FlutterLocalisation free and stop finding apostrophe bugs in production French.