ICU Nested Select Message Escaping Errors

ICU nested select messages throw cryptic parser errors when you confuse the doubled apostrophe '' (a literal ') with a single quote ' (which starts a literal-mode span), forget to quote a literal {, or unbalance braces between nested select and plural arms. This page isolates the apostrophe escaping rule, the '{' quoting rule for literal braces, the brace-balancing arithmetic of nested selectors, and the unescaped # errors that surface only inside a plural arm — with the exact error strings each one produces.

A message like {g, select, female {She said '{'hi'}'} other {They said hi}} looks balanced but may render the wrong thing — or throw Expected "}" but end of input found — depending on one misplaced quote. ICU’s quoting grammar is mode-based, not character-based: a lone ' does not escape the next character, it toggles a literal span that runs until the next '. Get that model wrong and an apostrophe in French copy (l'utilisateur) silently eats the rest of your message. These rules are the escaping-specific corner of the broader ICU Message Format Deep Dive.

ICU apostrophe quoting state machine The ICU parser starts in normal mode. A single apostrophe before a special character enters quoted literal mode until the next apostrophe. A doubled apostrophe always emits one literal apostrophe without changing mode. A dangling open quote consumes the rest of the message and breaks brace balance. Normal mode { } # are special Literal span '{' '}' '#' shown Emit one ' '' stays in mode Dangling quote eats rest → error ' then { '' closing ' no close
A lone apostrophe toggles a literal span; a doubled apostrophe emits one literal apostrophe and stays put. An unclosed span swallows the rest of the message and unbalances the braces.

Root cause analysis

ICU MessageFormat quoting is governed by the ICU4J MessagePattern grammar (and mirrored by intl-messageformat). Four independent rules account for nearly every escaping error.

A single apostrophe is a toggle, not a per-character escape. Unlike backslash escaping, ' does not escape only the next character. When ' is immediately followed by a special character ({, }, #, or |), it starts a quoted literal span that continues until the next '. Everything inside is emitted verbatim. So '{' is three characters that render a single {: open-quote, literal brace, close-quote. If you write '{ and forget the closing ', the literal span runs to the end of the string, consuming your closing } arms and producing an “unbalanced braces” or “end of input” error far from the real mistake.

A doubled apostrophe is always one literal apostrophe. '' emits exactly one ' and does not change quoting mode. This is the only way to show an apostrophe and it is locale-critical: French, Italian, and Catalan copy is full of apostrophes (l'image, dell'utente, l'usuari). Writing l'image (single quote) opens a literal span at l; everything after is quoted until ICU finds another ', so any {var} downstream renders as literal text. The fix is always l''image.

A lone ' not followed by a special character is just an apostrophe. ICU is lenient here: ' before a normal letter (it's) is treated as a literal apostrophe, no span. This leniency is exactly what hides the bug — it's fine works, so developers assume l'image {x} will too. It will not, because the apostrophe sits before nothing special but a later { flips ICU into thinking the span never closed in stricter parsers; behaviour differs between ICU4J and JavaScript ports, which is why the same string passes one build and breaks another.

Nested braces must balance per level. A select arm that contains a whole {count, plural, …} block adds one brace pair for the arm and one for the nested argument. Each # inside that nested plural must be a real value placeholder or a quoted '#' — an unescaped # meant as a literal hash silently becomes the count, while a stray { or } from unbalanced editing throws Expected "," or "}".

Minimal reproducible example

The smallest nested message that triggers an escaping failure at parse time:

{role, select,
  admin {The user said: '{'urgent'}'}
  guest {l'image was shared}
  other {{count, plural, one {# item} other {# items}}}
}

Two distinct faults hide here. The guest arm contains l'image — a single apostrophe before the normal letter i. In intl-messageformat this opens a literal span (because the parser is strict about lone quotes), so the brace counter never sees the arm close cleanly, and you get:

SyntaxError: Expected "}" but end of input found.

The error points at the end of the entire message, not the apostrophe, because the unclosed span ran all the way there. Separately, if you intended # in the other arm to be a literal hash (an issue reference, say) rather than the count, # item renders 1 item for count = 1 instead of # item.

A second classic: doubling the wrong token. '{'{name'}'} looks like an attempt to wrap {name} in literal braces, but '{' shows {, then {name opens a real argument that is never closed, then '}' shows }. The parser throws on the unterminated argument.

The fix, annotated

Use '' for every apostrophe, '{'/'}' for literal braces, and keep each nesting level’s braces balanced:

{role, select,
  admin {The user said: '{'urgent'}'.}
  guest {l''image was shared.}
  other {{count, plural,
    one {Issue '#'# has '{'one'}' reply.}
    other {Issue '#'# has '{'many'}' replies.}
  }}
}

Annotations on the non-obvious lines:

  • '{'urgent'}' is open-quote, literal {, the word urgent, then open-quote, literal }, close-quote — rendering {urgent} verbatim. Each literal brace needs its own quoted pair; you cannot wrap a whole span in one '...'.
  • l''image is the only correct way to show l'image. The doubled apostrophe emits one ' and never opens a span, so the rest of the arm parses normally. This same discipline is what keeps pluralization across languages safe in apostrophe-heavy locales like French.
  • Inside the nested plural, '#'# is a literal hash ('#') immediately followed by the live count (#), so count = 1 renders Issue #1. Quote any # you want shown literally; an unquoted # is always the count.
  • The other arm uses {{ … }} — the outer pair is the select arm, the inner pair is the nested plural argument. Count them: every { has a matching } at the same level. This composition mirrors the structure covered in ICU MessageFormat syntax for complex plurals.

For dynamic copy assembled from translator input, never interpolate raw apostrophes — escape them before they reach the formatter:

// Escape user/translator text for safe embedding in an ICU message arm.
function escapeIcuLiteral(text) {
  return text
    .replace(/'/g, "''")              // every apostrophe → doubled
    .replace(/([{}#|])/g, "'$1'");    // wrap each special char in a literal span
}

escapeIcuLiteral("l'image {x}"); // => "l''image '{'x'}'"

Verification snippet

Prove the escaping resolves to the literal output you expect, then assert it in CI so a regressed apostrophe fails the build:

import { IntlMessageFormat } from 'intl-messageformat';

const msg = new IntlMessageFormat(
  `{role, select,
     admin {said '{'urgent'}'}
     other {l''image: {count, plural, one {Issue '#'# } other {Issues}}}
   }`,
  'fr',
);

// Literal braces survive:
console.assert(msg.format({ role: 'admin' }).includes('{urgent}'), 'braces literal');
// Doubled apostrophe → one apostrophe, and '#'# → literal hash + count:
console.assert(
  msg.format({ role: 'guest', count: 1 }) === "l'image: Issue #1 ",
  'apostrophe + hash',
);

A static gate catches unbalanced quotes before runtime. eslint-plugin-formatjs parses every message at lint time and rejects malformed quoting, so a stray single apostrophe fails the build rather than a customer’s screen:

{
  "plugins": ["formatjs"],
  "rules": {
    "formatjs/no-literal-string-in-jsx": "off",
    "formatjs/enforce-default-message": "error",
    "formatjs/no-invalid-icu": "error"
  }
}

To confirm cross-runtime parity, parse the same string with the ICU4J-backed CLI (or @formatjs/cli compile) and diff the AST; a string that parses on the server but not the client almost always contains a lone apostrophe the two ports treat differently.

When to escalate

If a message still misbehaves after every apostrophe is doubled and every brace balanced, the problem has usually moved out of the string and into the toolchain. Some translation editors and .po/XLIFF round-trips re-encode '' back to a single ' on export, silently reintroducing the bug at build time — a corruption no in-source edit will hold. When that happens, the fix belongs in the extraction and storage layer rather than the message syntax, and the ICU Message Format Deep Dive cluster covers the formatter configuration, compiler flags, and storage-format escaping needed to keep '' intact from translator to runtime. If the breakage only appears under one locale’s data, escalate instead to the fallback chain handling, since a parser failure can demote the whole message to its fallback.

FAQ

What is the difference between '' and ' in an ICU message?

'' (two apostrophes) emits exactly one literal apostrophe and does not change parsing mode — it is the only correct way to show an apostrophe, essential for French or Italian copy like l''image. A single ' is a toggle: when it precedes a special character ({, }, #, |) it opens a quoted literal span that runs until the next '. A lone ' before an ordinary letter is treated as a literal apostrophe, which is why the bug is intermittent.

How do I show a literal { or } in an ICU message?

Wrap each brace in its own quoted span: '{' renders { and '}' renders }. You cannot wrap a longer phrase containing braces in a single '...' and expect the inner braces to stay literal — each literal brace needs its own quote pair. An unquoted { always starts an argument, so a stray one throws Expected "," or "}" at parse time.

Why does my French apostrophe break a nested select message?

A single apostrophe in l'image opens a literal span at the apostrophe and runs to the next ' in the message — often the end — swallowing your arm-closing braces and producing Expected "}" but end of input found. The span also hides any {var} after it, rendering placeholders as literal text. Double the apostrophe (l''image) so it emits one apostrophe without opening a span.

Part of ICU Message Format Deep Dive.