Pseudo-locale strings breaking ICU placeholders

A pseudolocalization pass that runs a character map over the raw catalogue produces messages that look right in a text editor and throw the moment they render:

FormatError: The intl string context variable "çôûñt" was not provided to the string "{çôûñt, plûràl, ôñé {# fìlé} ôthér {# fìléŝ}}"

The transform accented the argument name. The runtime is asked for a value called çôûñt, your code supplies count, and every message with an argument fails at once. This page explains why a character map cannot work, what the correct transform looks like, and how to prove it stayed correct.

Character-mapping a message versus walking its AST A naive character map accents the argument name, the plural keyword and the category keywords, so the message no longer parses against the supplied values and throws a missing value error. An ICU-aware walk transforms only the literal text inside each branch, leaving the structure intact so the message still renders. What a character map does to a message Naive character map · Input: {count, plural, one {# file} other {# files}} · Output: {çôûñt, plûràl, ôñé {# fìlé} …} · Result: MissingValueError at render ICU-aware walk · Input: {count, plural, one {# file} other {# files}} · Output: {count, plural, one {[!! # fìlé·· !!]} …} · Result: renders, and looks localized The argument name, the type keyword and every category keyword are structure — not text.
Only the text between the braces is text; everything else is structure the runtime matches on.

Root cause: a message is structure, not a string

An ICU message is a small program. {count, plural, one {# file} other {# files}} contains exactly one span of human-readable text per branch; everything else — the argument name count, the type keyword plural, the category keywords one and other, the # token — is syntax that the runtime matches against values you pass in.

A character map has no way to tell the two apart, because both are just characters in a string. It accents all of them. The argument name stops matching your values, the type keyword stops being recognised as a plural argument, and the category keywords no longer correspond to anything Intl.PluralRules can return. Depending on the library you get a thrown error, a silently unformatted message, or — the worst case — a message that renders the literal text {çôûñt, plûràl, …} to a user.

The same reasoning applies to rich-text tags. In Hello <b>{name}</b>, the tag name b is a lookup key into the component map your renderer was given. Accenting it to <b́> breaks that lookup exactly the way accenting an argument name does.

Transform rules for each ICU AST node type Literal nodes carry the visible text and are the only nodes transformed outright. Argument nodes must be left alone because their names are matched against the values you supply. Plural and select nodes are recursed into so their branch bodies are transformed while the keywords are preserved. The hash token is substituted by the runtime and must survive. Rich-text tag nodes are recursed into with their names preserved, because those names map to components. Which AST node types may be transformed Transform? Why literal yes this is the visible text argument no the name is matched against your values plural recurse into branches branch bodies are literals select recurse into branches branch bodies are literals pound (#) no a token the runtime substitutes tag recurse, keep the name rich-text tags map to components
Two node types are transformed, three are recursed into, and one must never be touched.

Minimal reproducible example

Four lines are enough to reproduce it, which means the failure can be pinned in a unit test rather than in a browser.

import IntlMessageFormat from 'intl-messageformat';

const source = '{count, plural, one {# file} other {# files}}';
const naive  = [...source].map((c) => ({ a:'à', c:'ç', e:'é', i:'ì', l:'l', n:'ñ',
                                         o:'ô', s:'ŝ', u:'ù' }[c] ?? c)).join('');

new IntlMessageFormat(source, 'en').format({ count: 3 });  // → "3 files"
new IntlMessageFormat(naive,  'en').format({ count: 3 });  // → throws: "çôûñt" not provided

The fix: walk the AST, transform only literals

Parse the message, then map over the node list. Literal nodes are transformed; argument nodes are copied verbatim; plural, select and tag nodes are recursed into so their branch bodies get transformed while their names and keywords survive.

import { parse, TYPE } from '@formatjs/icu-messageformat-parser';

const ACCENT: Record<string, string> = {
  a:'à', c:'ç', e:'é', i:'ì', n:'ñ', o:'ô', s:'ŝ', u:'ù', y:'ý',
  A:'À', C:'Ç', E:'É', I:'Ì', N:'Ñ', O:'Ô', S:'Ŝ', U:'Ù', Y:'Ý',
};

const accent = (t: string) =>
  [...t].map((ch) => ACCENT[ch] ?? ch).join('') + '·'.repeat(Math.ceil(t.length * 0.4));

function render(node: any): string {
  switch (node.type) {
    case TYPE.literal:
      return accent(node.value);                       // the ONLY text we touch
    case TYPE.argument:
      return `{${node.value}}`;                        // name copied verbatim
    case TYPE.pound:
      return '#';                                      // runtime substitutes this
    case TYPE.plural:
    case TYPE.select: {
      const kind = node.type === TYPE.plural ? 'plural' : 'select';
      const offset = node.offset ? `, offset:${node.offset}` : '';
      const branches = Object.entries(node.options)
        .map(([key, opt]: any) => `${key} {${opt.value.map(render).join('')}}`)
        .join(' ');
      return `{${node.value}, ${kind}${offset}, ${branches}}`;
    }
    case TYPE.tag:
      return `<${node.value}>${node.children.map(render).join('')}</${node.value}>`;
    default:
      // date, time, number: the argument and its skeleton are both structure
      return `{${node.value}${node.style ? `, ${node.style}` : ''}}`;
  }
}

export const pseudo = (msg: string) => `[!! ${parse(msg).map(render).join('')} !!]`;

Three details in that function matter. The offset on a plural node has to be carried through, or an offset message silently changes meaning. Branch keys including exact-match forms such as =0 are copied as keys, never as text. And the wrapper markers are applied once around the whole message rather than around each literal, so a marker appearing mid-sentence still means what it is supposed to mean — that the sentence was assembled from more than one catalogue entry.

Verification

Assert on structure, not on the accented output. Two properties are enough: the pseudo message parses, and its argument name set is identical to the source’s.

import { parse, TYPE } from '@formatjs/icu-messageformat-parser';

const argNames = (msg: string): string[] => {
  const out: string[] = [];
  const walk = (nodes: any[]) => nodes.forEach((n) => {
    if (n.value && n.type !== TYPE.literal && n.type !== TYPE.pound) out.push(n.value);
    if (n.options) Object.values(n.options).forEach((o: any) => walk(o.value));
    if (n.children) walk(n.children);
  });
  walk(parse(msg));
  return out.sort();
};

test.each(Object.keys(source))('%s keeps its arguments', (key) => {
  expect(() => parse(pseudoCatalog[key])).not.toThrow();
  expect(argNames(pseudoCatalog[key])).toEqual(argNames(source[key]));
});

Running that test over the whole catalogue takes well under a second and fails on the exact key that regressed, which is the property that makes it worth having in the pipeline described in localization testing and pseudolocalization.

Isolating a broken pseudo message The runtime error names the failing key. Parsing just that pseudo message reproduces the problem without the application. Comparing the argument name set of the source message against the pseudo message shows exactly which name was altered, and any difference at all is the defect. Reproducing the failure in four lines 1 Take one failing key the error message names it 2 Parse the pseudo message alone parse(pseudo[key]) 3 Compare argument name sets source vs pseudo 4 Any difference is the bug no rendering needed
The comparison is the whole test — it needs neither a browser nor a component tree.

When to escalate

If the argument sets match and messages still fail, the problem is no longer the transform. Three causes are worth checking in order.

The source catalogue may contain malformed ICU that the parse step is now surfacing for the first time. That is a real bug the pseudo pass found rather than caused, and it belongs in the ICU message format reference — most often an unescaped apostrophe or an unbalanced brace.

The renderer may be receiving a different value shape than the message expects. A message expecting {count} as a number will misbehave when handed a string in some libraries, and the pseudo pass has nothing to do with it.

Finally, the accent map may contain a glyph your shipped font subset does not include. The message is then structurally perfect and renders as boxes, which reads like a transform bug but is a font-loading bug. Restrict the accent map to characters your font actually carries.

Ordering: where the transform belongs in the build

Two pipeline positions look equivalent and are not.

Running the transform before compilation — on the same JSON the real translations are authored in — means the pseudo bundle passes through every subsequent step exactly as a real locale does. It is compiled by the same compiler, bundled by the same bundler, and loaded by the same loader. If any of those steps has a locale-specific bug, the pseudo run finds it.

Running the transform after compilation means operating on generated artifacts: Lingui’s compiled message functions, Angular’s inlined bundles, or a minified JSON blob. At that point the message is no longer a message; it is code, and a character map over code is a category error that produces broken JavaScript rather than broken text.

The same reasoning explains a subtler failure. If your build extracts messages, compiles them, and then generates the pseudo bundle from the extracted source, the pseudo bundle skips the compile step — and a compile-time bug in message handling stays invisible. Generate early, compile everything together, and the pseudo locale earns its keep as a canary for the whole pipeline rather than only for the transform.

One practical consequence: the transform must be idempotent-safe against accidental double application. Running it twice produces doubly-accented, doubly-padded text that still parses, so nothing fails — you simply get a bundle that over-reports expansion. Writing the output to a distinct locale tag, rather than in place, makes double application impossible by construction.

FAQ

Why not just skip anything inside braces?

Because braces nest. A plural message contains branch bodies inside braces, and those bodies are exactly the text you want to transform. A regular expression that skips brace-delimited spans either skips the text you need or fails on nesting; both outcomes are worse than parsing, which is a single dependency and a dozen lines.

Does this apply to gettext-style catalogues too?

Yes, with different syntax. In printf-style formats the tokens are %s, %1$d and friends, and a character map will happily accent them into meaninglessness. The rule generalises: identify the format’s substitution syntax, leave it alone, transform only what surrounds it — see PO and XLIFF format bridging for how those tokens are represented across formats.

Should the pseudo transform run on the compiled catalogue or the source?

On the source. Compiled catalogues — Lingui’s function output, for instance — have already turned the message into code, and transforming code is a different and much worse problem. Generate the pseudo bundle from the same source the real translations come from, then compile it through the identical pipeline so the compile step is exercised too.

Why did the transform work last month and break today?

Almost always because a new message introduced a syntax the transform does not handle. Rich-text tags, date and time skeletons, and nested selects each need their own branch in the renderer, and a transform written against a catalogue of simple strings will silently mishandle the first message that uses one. Making the renderer throw on an unknown node type, rather than falling through to a default that stringifies it, converts a silent corruption into a build failure that names the message.

Can the pseudo transform run in the browser instead of the build?

It can, and it is a reasonable development convenience — a query parameter that swaps the catalogue for a transformed copy gives instant feedback without a rebuild. It is not a substitute for the build-time bundle, because it skips exactly the pipeline steps that most often break: extraction, compilation and bundling. Use it to look at a screen quickly; use the generated bundle for anything that gates a merge.

Part of Localization Testing & Pseudolocalization.