Developing a good web app involves to implement an efficient internationalization system. You have to provide the same content in multiple languages, so you need a module to translate all your texts.

This article will explain how to build a simple translation system (or i18n module) for all your application texts. I don’t consider other data like dates or phone numbers which are a huge mess but can be implemented in the same way as text.

Build the Singleton

Firstly we need to write a simple Singleton as explained in this article. I take the exact code from the end of the article, which is :

class Translator {
  constructor() {
    this.language = "fr";
  }
 
  get(text) {
    // doSomething
  }
}
 
export default new Translator();

We can now call something like Translator.get('YES') from anywhere in our code by importing the module.

Analyze logic behind the translation

Before implementing the translation module, we have to think about the features of the module.

Here are some examples of features I want :

InputEnglishFrench
('HELLO')HelloBonjour
('HELLO', 'Florian')Hello FlorianBonjour Florian
('YOU_HAVE_N_MESSAGES', 9)You have 9 messagesVous avez 9 messages
('YOU_HAVE_N_MESSAGES', 1)You have 1 messageVous avez 1 message

So I need a module that can take some parameters to include them in the translated texts. I also need something to configure translations according to the parameter (for example, to create a singular or a plural word).

I’ll use a particular combination of characters to determine where I must replace the texts with parameters. This combination is $-, and we replace each presence of this combination with the argument of Translator.get.

Let’s write i18n files for English and French:

{
  "NO": "Non",
  "YES": "Oui",
 
  "HELLO": {
    "null": "Bonjour",
    "N": "Bonjour $-"
  },
  "YOU_HAVE_N_MESSAGES": {
    "null": "Vous n'avez pas de messages",
    "0": "Vous n'avez pas de messages",
    "1": "Vous avez 1 message",
    "N": "Vous avez $- messages"
  }
}
{
  "NO": "Non",
  "YES": "Oui",
 
  "HELLO": {
    "null": "Bonjour",
    "N": "Bonjour $-"
  },
  "YOU_HAVE_N_MESSAGES": {
    "null": "Vous n'avez pas de messages",
    "0": "Vous n'avez pas de messages",
    "1": "Vous avez 1 message",
    "N": "Vous avez $- messages"
  }
}

We define JSON objects where each key can contain a string or an object with keys :

  • "null" for no argument
  • "0" / "1" / "2" / "test" / … to define a specific case for the argument
  • "N" for all cases not defined for argument

Implementation of the get() function

It’s pretty simple to add the argument in the translation. We use a regex within the String.replace function, and we already finished this part.

_insertArguments = (text, arg) => {
  if (!arg) {
    return text;
  }
 
  return text.replace(/\$\-/g, arg);
};

Now, we have to get the translation and use the good case according to the argument. First, get the translation and use it if this is a string. If we can’t find any translation, return the text.

const currentTranslation = this.translations[text.toUpperCase()];
 
if (!currentTranslation) {
  return text;
}
 
if (typeof currentTranslation === "string") {
  return this._insertArguments(currentTranslation, arg);
}

If it isn’t a string, we assume it’s an object. So we have to check if there’s an argument. If not, use the null entry of the translation. We also added a check if we have to set a null translation, but there’s no null transaction.

if (arg === undefined || arg === null) {
  if (!currentTranslation.null) {
    throw new Error(`Cannot find null translation for ${text}`);
  }
 
  return currentTranslation.null;
}

Now, there’s an argument. Let’s check if there’s a translation entry with this value and use it if it exists. We cast the argument explicitly to a String because it’s nice to respect typing.

if (currentTranslation[String(arg)]) {
  return this._insertArguments(currentTranslation[String(arg)], arg);
}

Last but not least, we are in the N case. So get the N translation and insert the argument into it.

if (!currentTranslation.N) {
  throw new Error(`Cannot find N translation for ${text}`);
}
 
return this._insertArguments(currentTranslation.N, arg);

We also add a setLanguage to the Translator to … set the language. Yeah, not very original.

setLanguage = (newLang) => {
  switch (newLang) {
    case "fr-FR":
      this.language = "fr-FR";
      this.translations = FR_FR_TRANSLATIONS;
      break;
    case "en-US":
    default:
      this.language = "en-US";
      this.translations = EN_US_TRANSLATIONS;
  }
};

That’s it. We have a simple i18n module for all of our texts.

All the Translator code is available in this gist.


I hope you found this short article pleasant. Don’t hesitate to tell me what you have thought of this one on Twitter. I’ll be happy to discuss it with you :)

Thanks for reading! Love on you! Thanks for reading! Love on you!