DEV Community

Melisa Wong
Melisa Wong

Posted on

My i18n function takes eleven positional arguments. Here is what that cost me.

Let me start with the real signature, because it explains everything that follows.

export function mainText(
  lang: AppLanguage,
  en: string,
  esText?: string,
  ruText?: string,
  ptText?: string,
  frText?: string,
  itText?: string,
  arText?: string,
  deText?: string,
  hiText?: string,
  idText?: string,
): string {
  if (lang === 'es' && esText) return esText;
  if (lang === 'ru' && ruText) return ruText;
  if (lang === 'pt' && ptText) return ptText;
  if (lang === 'fr' && frText) return frText;
  if (lang === 'it' && itText) return itText;
  if (lang === 'ar' && arText) return arText;
  if (lang === 'de' && deText) return deText;
  if (lang === 'hi' && hiText) return hiText;
  if (lang === 'id' && idText) return idText;
  return en;
}
Enter fullscreen mode Exit fullscreen mode

Eleven positional arguments. One if per language. English as the fallback. No keys, no lookup table, no t('some.nested.key').

This is the entire UI translation layer of an app that ships in ten languages, and it is called roughly 150 times.

I wrote it, and it works. But I want to walk through what it cost me, because the cost is not where you would expect it to be.

Why I wrote it this way

I want to be fair to the original decision, because it was not careless. It was a trade I made on purpose.

The alternative is the standard key-based API:

t('createName.firstName') // → 'Nombre / 名'
Enter fullscreen mode Exit fullscreen mode

For this project, the problem with that was friction at the call site. When I write UI, I want to see the actual string I am rendering. With mainText(...) I get the English, the Spanish, the Arabic — all of it — right there in the JSX. I never have to open a second file to know what the user reads. And when I add a language, TypeScript hands me a signature that lists every slot, so the editor tells me what to fill in.

For a solo project with a fixed set of languages, that is genuinely pleasant. It is a local-optimization win.

It is also a trap. Here is why.

Cost 1 — parameter order becomes an invisible contract

Read the order again: en, es, ru, pt, fr, it, ar, de, hi, id.

That is not alphabetical. I ordered them by when I happened to write them, which is exactly the kind of ordering that feels obvious to the person who chose it and is invisible to everyone else — including me, three months later.

Now look at a real call site:

title={mainText(language, 'Listen', 'Escuchar', 'Слушать', 'Ouvir', 'Écouter', 'Ascolta', 'استمع', 'Hören', 'सुनें', 'Dengar')}
Enter fullscreen mode Exit fullscreen mode

Ask yourself: if you were writing this line late at night, which slot would you get wrong?

Nothing enforces the order. Every argument is a string | undefined. If you swap the Portuguese and Russian slots, TypeScript is completely happy. You ship a Russian string to Portuguese users, the build passes, and the only thing that catches it is a human reading the Portuguese UI.

That is the worst class of bug: it type-checks, and it is silent.

Cost 2 — a missing argument fails silently, in English

Look at the fallback again:

if (lang === 'es' && esText) return esText;
// ...
return en;
Enter fullscreen mode Exit fullscreen mode

Two conditions must both hold: the language matches, and the string is present. Forget to pass the Spanish string, and esText is undefined, the condition fails, and the function returns English.

Nothing throws. Nothing logs. The Spanish user simply sees one English button in an otherwise Spanish screen.

With ten languages and ~150 call sites, that is not hypothetical — it is a matter of time. And because English is a legitimate, expected outcome, you cannot flag it at runtime without drowning in noise.

The fix is boring, and it took me too long to apply: make the arguments required instead of optional, and add a dev-only assertion that every language has a value. Optional parameters trade compile-time safety for call-site convenience, and in an i18n layer that is the wrong trade.

Cost 3 — the source language is not a language

This one is the most interesting, and it is specific to how this product works.

Every screen shows the target language on the main line and Chinese underneath it, always. Not behind a toggle. Not as a second language pack. Chinese is rendered alongside every other language because it is the content — the thing being translated, shown in parallel.

So the language packs do not look like this:

firstName: 'Nombre',
Enter fullscreen mode Exit fullscreen mode

They look like this:

firstName: 'Nombre / 名',
gender: 'Género / 性别',
Enter fullscreen mode Exit fullscreen mode

The Chinese is welded directly into each of the nine packs. One field renders the whole bilingual line, so the UI code stays trivial.

That has a consequence I got wrong for a while: the app has no Chinese interface, and it never should. Chinese is not the eleventh language — it is the source that all ten languages are stacked on top of. For a while the type union still allowed 'zh', and a test build that injected ns_lang=zh would drop the user into a "pure Chinese interface" that did not actually exist. A ghost locale. I sealed it by removing zh from every runtime whitelist — detection, persistence, selector — while keeping the type honest.

The lesson generalizes: decide whether your source language is a language or an input. If it is an input, it must not be selectable. Leave it selectable "just in case", and you will eventually ship a fake locale.

Cost 4 — RTL is not dir="rtl"

Arabic is in the set, so there is one line that flips the document:

useEffect(() => {
  const isRTL = language === 'ar';
  document.documentElement.lang = language;
  document.documentElement.dir = isRTL ? 'rtl' : 'ltr';
}, [language]);
Enter fullscreen mode Exit fullscreen mode

That handles maybe 80% of it. The remaining 20% is where the bugs live:

  • Mixed-direction lines. A badge showing a Latin-script name next to Chinese characters does not simply mirror — the runs reorder and the punctuation lands on the wrong side. That needed explicit LTR isolation on the badge itself, not on the page.
  • The Chinese helper line. The Chinese sub-line should still read left-to-right inside an RTL page. Let it inherit rtl and the punctuation jumps to the start of the line.
  • Numbers and Latin text inside Arabic sentences. Fine at the document level, broken the moment they sit next to a bracket or a slash.

The systematic fix is logical CSS properties: margin-inline-start instead of margin-left, padding-inline instead of padding-left, text-align: start instead of left. Every physical property you write is a future RTL bug you have not found yet.

Cost 5 — machine translation hides its own failures

The languages beyond English and Chinese came from batched machine translation. Two things I learned the hard way.

Truncation looks like a short translation. Some model responses quietly summarize long paragraphs instead of translating them. There is no error — you get a shorter, plausible string. The cheapest detector is a ratio check: flag any translated field whose length falls below ~30% or above ~300% of the source. That one heuristic caught most of my bad fields.

Batch size is a reliability knob, not a speed knob. Bigger batches finish faster and fail more often, with empty responses under rate limiting. Small batches with a resumable log — key, source, translation, model, request id — let me rerun only what failed and never silently drop a field.

What I would do instead

If I were starting over, the checklist is short:

  1. One exported array of language codes is the single source of truth. The type, the detector, the whitelist and any iteration all derive from it, so adding a language touches one place.
  2. Translation values live in an object keyed by language code, not in positional slots. A missing language becomes a type error instead of a silent English fallback.
  3. Required, not optional. If a language must have a value, say so in the type.
  4. Logical CSS properties from day one — RTL is a writing mode, not a stylesheet rewrite.
  5. The source language is an input, never a selectable locale.
  6. Machine translation ships with an audit log and a length-ratio sanity check.

The positional signature still works, and at 150 call sites the cost of rewriting it has not yet beaten the cost of leaving it. But it is the one decision in this codebase I would take back first.

I build NameSoul, a Chinese naming app that currently runs in ten languages. The i18n layer above is the part of it I still think about the most.

Top comments (0)