DEV Community

Evgenii Sokolov
Evgenii Sokolov

Posted on

5 ARB and ICU edge cases I wish I'd tested earlier

When I first added localization-file conversion, the job looked almost embarrassingly small: parse a file, copy the strings into a map, serialize that map in another format.

Then I tried it on real Flutter projects.

The converted files parsed correctly, which felt like success, but some of them had quietly lost translator context. Others kept the text and damaged a placeholder. ICU plurals were the most misleading case: a message could be perfectly valid and still be wrong for the target language.

These are the five fixtures I now reach for before I trust a converter.

1. @key is not another string

An ARB message can have a companion metadata entry:

{
  "welcome": "Welcome, {name}!",
  "@welcome": {
    "description": "Greeting shown after sign-in",
    "placeholders": {
      "name": {
        "type": "String"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

welcome is the text. @welcome tells Flutter and the translator what that text means.

My first parser skipped every key beginning with @. That was convenient because metadata should not appear in the translation list. It also meant the conversion was not lossless. A round trip restored the greeting but forgot its description and the type of name.

The model I use internally now looks more like this:

id: "welcome"
value: "Welcome, {name}!"
description: "Greeting shown after sign-in"
placeholders: ["name"]
Enter fullscreen mode Exit fullscreen mode

That does not magically solve export. CSV, for example, may have nowhere to put a placeholder type. The important part is knowing what was lost. Depending on the target format, I can keep metadata in comments, write a sidecar file, or show a warning.

I also test @missing without a matching missing message. Orphan metadata looks odd, but it turns up in repositories after keys are renamed or deleted.

2. A valid plural can still be wrong for the locale

This is enough for most English cardinal plurals:

{count, plural,
  one {# file}
  other {# files}
}
Enter fullscreen mode Exit fullscreen mode

Using the same branches for Russian loses information:

{count, plural,
  one {# файл}
  few {# файла}
  many {# файлов}
  other {# файла}
}
Enter fullscreen mode Exit fullscreen mode

English normally needs one and other. Russian uses one, few, many, plus the required other fallback. Arabic may use all six named categories: zero, one, two, few, many, and other.

So I treat validation as two separate questions:

  1. Does the ICU message parse?
  2. Does it cover the categories used by this locale?

Checking only the first question catches unbalanced braces and a missing other, but it will not tell you that the Russian few branch is absent.

Exact selectors need to survive as well:

{count, plural,
  =0 {No files}
  one {One file}
  other {# files}
}
Enter fullscreen mode Exit fullscreen mode

=0 is an exact-number override. It is not the same thing as the locale category zero, and a converter should not merge the two.

Then there is nesting:

{gender, select,
  female {{count, plural, one {She has one task} other {She has # tasks}}}
  male   {{count, plural, one {He has one task} other {He has # tasks}}}
  other  {{count, plural, one {They have one task} other {They have # tasks}}}
}
Enter fullscreen mode Exit fullscreen mode

This was the point where I stopped trying to count braces with regular expressions.

3. One value has to satisfy both JSON and ICU

ARB is JSON, but its string values may contain another language: ICU MessageFormat. The converter has to get through both layers without "helping" too much.

{
  "receipt": "Line 1\nLine 2: \"{total}\"",
  "path": "C:\\Exports\\{locale}\\messages.json",
  "emoji": "Saved ✓ 🚀"
}
Enter fullscreen mode Exit fullscreen mode

After JSON parsing, receipt contains a real newline. path contains backslashes and an ICU-style placeholder. The last value is a quick check that the pipeline is genuinely Unicode-safe.

The bugs here are small and annoying:

  • \n becomes the visible characters \ and n;
  • an escaped backslash is escaped a second time;
  • UTF-8 is saved with an unexpected byte-order mark;
  • ICU apostrophe quoting changes in one direction of the conversion;
  • Unicode normalization changes a value that looks identical on screen.

I compare decoded values, not their JSON spelling. These two strings represent the same character:

"\u2713"
"✓"
Enter fullscreen mode Exit fullscreen mode

Byte equality would fail that test for no useful reason. On the other hand, a changed non-breaking space can look fine in a diff and still break the UI.

4. Not every brace is a placeholder

Localization projects rarely agree on one placeholder dialect:

Hello, {name}
Downloaded %s of %d files
You have :count messages
Welcome, {{user}}
Price: %(amount)s
Enter fullscreen mode Exit fullscreen mode

Unless the user explicitly asks for a syntax migration, I leave those tokens alone. I also compare them as a multiset rather than a set:

input:  ["{name}", "{count}", "{count}"]
output: ["{name}", "{count}", "{count}"]
Enter fullscreen mode Exit fullscreen mode

The repeated {count} matters.

The opposite problem is detecting placeholders where there are none:

CSS: .button { color: red; }
Discount: 20%
Object: {"id": 42}
Enter fullscreen mode Exit fullscreen mode

A single broad regular expression will eventually eat ordinary text. Format-aware parsing is better. If that is not possible, I would rather ask which placeholder dialect a file uses than silently guess.

ARB gives us one extra consistency check. Tokens in the message should agree with the placeholders object in @key. If the text contains {name} but the metadata only declares count, that deserves a warning.

5. "It parses" is a very low bar for a round trip

Consider this ARB file:

{
  "@@locale": "en",
  "save": "Save",
  "@save": {
    "description": "Button label, not a noun"
  }
}
Enter fullscreen mode Exit fullscreen mode

A simple CSV export might be:

key,value
save,Save
Enter fullscreen mode Exit fullscreen mode

The CSV is valid. Turning it back into this ARB is also valid:

{
  "save": "Save"
}
Enter fullscreen mode Exit fullscreen mode

But the locale and the translator's context have disappeared.

For a round-trip test, I compare message IDs, decoded values, placeholder counts, ICU selectors, metadata, and locale information. I do not require the same key order, whitespace, or Unicode escape style.

When the target format cannot represent something, I want the result described plainly:

lossless
lossy (with a reason)
unsupported
Enter fullscreen mode Exit fullscreen mode

Returning a green checkmark merely because the output parses is how quiet data loss reaches a production translation file.

The small fixture I keep around

This one file covers most of the cases above:

{
  "@@locale": "en",
  "welcome": "Welcome, {name}!\nYou have {count, plural, =0 {no tasks} one {# task} other {# tasks}}.",
  "@welcome": {
    "description": "Dashboard greeting",
    "placeholders": {
      "name": {"type": "String"},
      "count": {"type": "int"}
    }
  },
  "exportPath": "C:\\Exports\\{name}\\✓"
}
Enter fullscreen mode Exit fullscreen mode

For each target format, I verify what it can preserve and expect an explicit limitation for everything else. Then I convert it back and compare meaning rather than bytes.

I put two tools I use while chasing these cases online: an ARB-to-JSON converter and an ICU plural validator. They work without signup.

One honest caveat: the converter currently extracts translatable keys and values but does not carry ARB @key metadata into flat target formats. Treat that conversion as lossy.

Disclosure: both tools are part of Localization.One, a product I am building. If you have a file that breaks either tool, a minimal reproducible example would be genuinely useful.

Top comments (0)