Join early access

Translation keys or source text? The key decides what an edit means

The keys-versus-text debate is argued as readability against maintenance. The key's real job is deciding whether an edited source string is still the same message, and neither choice records the one fact that decides it.

Four wall hooks with identical blank round tags; three hold the same coat and the fourth holds a closed umbrella, drawn in white line on a slate ground

Keys against text is argued as the wrong trade-off

Every localized codebase makes one early choice about its strings: what to look them up by. Either the English text is the key, as in gettext("Save changes"), or an identifier is, as in t("settings.save"). The question gets asked in plain words, “Why do you use ids as translation strings?”, by people who have just watched a product show raw identifiers instead of words 1.

The usual answers trade readability against maintenance. Text keys read well in code and give the translator a real sentence. Opaque keys let the copy change without touching the code. Both are true, and neither is the part that matters most.

A key decides what counts as the same message over time. So it decides what an edit to the English does to every translation that already exists. Change the text and one of three things happens: the translations are thrown away, they are flagged for a person to check, or they are kept without a word. Which one you get was settled the day the key scheme was chosen, and it is rarely the one the edit called for.

When the text is the key, every edit is a new message

GNU gettext is the reference design for text as the key. The source string is the msgid, and when no translation exists “the argument itself is returned” 2. A missing translation shows English, never an identifier. That is a real strength, and it is what the Hacker News question was asking for.

The cost shows up on the first edit. The edited string is a different msgid, so it is a different message. The tools soften this. When msgmerge “hypothesises that some new msgid has been modified only slightly out of an older one”, it pairs the old translation with the new entry and marks it fuzzy for review 3. With --previous it keeps the old source beside it, so the translator can see what moved 4.

text
#, fuzzy#| msgid "You can undo this."msgid "You can't undo this."msgstr "Vous pouvez annuler cette action."
Schematic PO entry after a merge. The previous source is kept on the #| line; the French still says the action can be undone.

That is the safe outcome for a change like this one, where a negation flipped the meaning. The compiler leaves fuzzy entries out of the build by default, and the manual calls including them “usually wrong, because fuzzy messages are exactly those which have not been validated by a human translator” 5. So French users see the new English until someone reviews it. The wrong French never ships.

Now make the edit a typo fix. “Recieve updates” becomes “Receive updates”. The French was correct before and it is still correct. The mechanism cannot tell. The entry goes fuzzy in every locale, drops out of every build, and every user outside English sees English until a translator clears a flag on a change that meant nothing to them.

Generated ids behave the same way under another name. When a FormatJS message has no explicit id, the extractor makes one from a content hash, [sha512:contenthash:base64:6] by default 6. The content hashed is the default message, joined with its description when there is one 7. Edit a comma and the id changes, and the old translation is filed under an id nothing asks for any more. Text as identity over-invalidates. It treats a new spelling as a new message.

When an id is the key, the old translation survives a new meaning

An opaque key fixes that. The copy can change as often as it likes and settings.save is still settings.save, so every translation survives a typo fix. It also survives every other edit.

text
en.json   "delete.undoHint": "You can't undo this."fr.json   "delete.undoHint": "Vous pouvez annuler cette action."
Schematic. The English was edited to flip its meaning; the key did not move, so the French kept promising the opposite.

Nothing in a key-value lookup can object. The key matched, so the translation is returned. This is the dangerous half of the pair, because a stale translation looks exactly like a correct one. There is no fallback to spot, no raw identifier on screen and no failing test. It is a well-formed sentence in the right language that says something the product no longer does.

Mozilla, whose toolchain identifies strings by id, answers with a rule for authors: “If you are changing a string such that its meaning has changed, you must update the string ID.” A new id is “treated as a brand new string and reported as missing translations in all tools”, which the page calls “the only reliable method to ensure that localizers update existing localizations, and run-time stops using obsolete translations”. For a typo or a capitalization fix, the id generally stays 8.

The trade is visible from the other side too. i18next documents letting the key itself serve as the source-language text, so no fallback language has to load. It calls that “Possible - but not recommended”, because “you will need to update changes to fallback values in code and JSON files”. And a key that finds no value is shown as-is: “the key acts as fallback” 9. That is how a product ends up showing settings.save to a user. Opaque identity under-invalidates. It treats a new meaning as the same message unless a person remembers to say otherwise.

Identical text is not an identity

Text as identity has a second failure, and it happens before anything is edited. Two messages can share a spelling and not a meaning. The gettext manual saw this early. Its comparison with the older catgets interface admits that a single word used in different places may need different translations, and says the authors decided the problem “does not weight that much” 10.

It weighed more in interfaces, where strings are short. The later section on contexts opens by saying the gettext functions, used normally, have “big problems” in graphical programs, “especially true for the one-word strings which are frequently used in GUI programs”. Its example is two menus that both contain Open, fixed by adding a msgctxt so the same text can carry two translations 11.

Id-based systems get this for free and pay for it in discipline. Qt’s text ids take no context parameter, “and therefore identically spelled words with different meanings need separate text IDs”. The same page notes that until a translation arrives, “the text ID will be shown in the user interface rather than a proper text” 12.

We have written about the translation-memory version of this, where one source segment resolves to two approved targets. The catalog version is the mirror image: a text key has already merged every homonym before a translator sees one. A context field is text identity admitting that text was never enough.

The fact nobody writes down

Put the two schemes side by side and the pattern is plain. The text key invalidates on every change of spelling. The opaque key invalidates on no change at all. What each edit actually needed was to invalidate on a change of meaning, and meaning is the one thing neither scheme stores.

Fuzzy matching is a guess at that fact from edit distance, and edit distance measures spelling. “You can undo this” to “You can’t undo this” is a tiny edit and a reversed promise. “Sign in” to “Log in” rewrites most of the string and, in many products, means exactly the same thing. A rule that keeps translations when the edit is small keeps the reversed promise and throws away the synonym.

The fact does exist, briefly. The person making the edit knows why they made it. That is why Mozilla’s answer is a rule for that person rather than a feature of the tools. It works to the extent people follow it, which makes it a convention, and a convention has a failure rate.

Mozilla’s own page goes further. It says there is “a gray area between needing a new ID or not”, and that sometimes “it will be necessary to look at all the existing translations to determine if a new ID would be beneficial” 8. Whether an English edit changed the meaning can depend on the target language. The editor cannot always know from English alone.

We made a similar argument about deciding which strings are translatable: a mark made at authoring time is cheaper than any classifier, because it records the fact while the person still has it. The intent behind a source edit is the same kind of fact. It exists for a moment, in one head, and every scheme above lets it go.

Make the edit say what it means

The way out is not a better key. It is to stop asking the key to carry two jobs. Let an identifier say which message this is, so a typo fix never costs a retranslation and two homonyms never collide. Then track the source text as a separate fact, so a change to it can be seen instead of silently absorbed.

Standards already have a place for the result. XLIFF 1.2 defines a target state needs-review-translation, which “Indicates that only the text of the item needs to be reviewed” 13. What the standard cannot do is set it. Something has to notice that the source moved, and someone has to say whether it moved in meaning.

In practice that comes down to three habits.

  • Store, beside each translation, the source text it was made from. A source edit is then a detectable event, not an invisible one.
  • Treat a detected change as needing review by default, and let the editor downgrade it explicitly, as a copy fix that keeps translations, at the moment of the edit. Silence should mean “check it”, never “keep it”.
  • When in doubt, ask the people who hold the target languages, as Mozilla’s page tells its own engineers to do. The gray area is real, and it lives on their side.

Whatever scheme you already have, you can find out which way it fails. Edit the English in one string, build, and look at another locale. If you see English, your keys over-invalidate. If you see the old translation with no flag anywhere, they under-invalidate, and that is the one to fix first.

The picture at the top of this post is that second case. Every tag on the wall is identical and every tag is still attached. The tags never lied about which hook they belong to. Nobody asked them what was hanging there.

References

  1. 1.Hacker News, 2022 Ask HN: Why do you use ids as translation strings? news.ycombinator.com, 20 June 2022
  2. 2.GNU gettext manual Interface to gettext GNU gettext utilities
  3. 3.GNU gettext manual Fuzzy Entries GNU gettext utilities
  4. 4.GNU gettext manual Invoking the msgmerge Program GNU gettext utilities
  5. 5.GNU gettext manual Invoking the msgfmt Program GNU gettext utilities
  6. 6.FormatJS CLI FormatJS documentation
  7. 7.FormatJS babel-plugin-formatjs, utils.ts GitHub, formatjs/formatjs
  8. 8.Mozilla Making changes to existing strings Localization at Mozilla, internal documentation
  9. 9.i18next Fallback i18next documentation
  10. 10.GNU gettext manual Comparing the Two Interfaces GNU gettext utilities
  11. 11.GNU gettext manual Using contexts for solving ambiguities GNU gettext utilities
  12. 12.The Qt Company Text ID based translations Qt Linguist Manual, Qt 6
  13. 13.OASIS XLIFF Version 1.2 OASIS Standard, 2008