Skip to content

6. Internationalization

6.1. Example 21: A Bilingual Application

The [RdvMedecins] application is available in French and English. In the [NestJS] course, the server used to translate the pages before sending them. Now, the pages are generated in the browser: the client translates them, and switching languages no longer requires any interaction with the server.

[Angular] has its own translation system, [@angular/localize], which generates a compiled application for each language: switching languages therefore simply involves loading a different application. Instead, we’ll use the [ngx-translate] library, the most commonly used one alongside [Angular], which translates at runtime: a single click is all it takes to retranslate the entire page.

1
2
3
4
5
6
7
8
21-i18n/src/app/
├── i18n/
│   ├── fr.json          the French dictionary
│   ├── en.json          the English dictionary
│   ├── i18n.ts          the configuration of ngx-translate
│   └── plural.pipe.ts   the choice of the plural
├── app.ts
└── app.html

The library is declared in the [package.json] file in the workspace:

        "@ngx-translate/core": "^18.0.0",

6.1.1. Dictionaries

{
  "APP": {
    "TITLE": "Exemple 21 - une application en deux langues",
    "LANGUAGE": "Langue"
  },
  "HOME": {
    "HELLO": "Bonjour {{name}} !",
    "TODAY": "Nous sommes le {{date}}.",
    "PRICE": "Une consultation coûte {{price}}.",
    "APPOINTMENTS": {
      "zero": "aucun rendez-vous",
      "one": "un rendez-vous",
      "other": "{{n}} rendez-vous"
    },
    "ADD": "Ajouter un rendez-vous",
    "REMOVE": "En retirer un"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s)."
  },
  "SERVER": {
    "TITLE": "Traduire une réponse du serveur",
    "EXPLANATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
  }
}
{
  "APP": {
    "TITLE": "Example 21 - a two-language application",
    "LANGUAGE": "Language"
  },
  "HOME": {
    "HELLO": "Hello {{name}}!",
    "TODAY": "Today is {{date}}.",
    "PRICE": "A consultation costs {{price}}.",
    "APPOINTMENTS": {
      "zero": "no appointment",
      "one": "one appointment",
      "other": "{{n}} appointments"
    },
    "ADD": "Add an appointment",
    "REMOVE": "Remove one"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s)."
  },
  "SERVER": {
    "TITLE": "Translating a server response",
    "EXPLANATION": "The RdvMedecins server only sends keys. The client translates them:"
  }
}
  • Each text has a key consisting of the names of the successive levels: [HOME.HELLO], [ERRORS.LOGIN_TAKEN]... The code and templates no longer contain any text, only keys;
  • line 7: parameters are written between double curly braces: [{{name}}];
  • lines 10–14: plural forms, grouped under the same key (see below);
  • lines 18–21: the keys [ERRORS.*] are those returned by the server [RdvMedecins] in its error responses (next chapter). The server does not return text, but a key; the client translates it into the user’s language.

6.1.2. The configuration

// =========================================================================
// 21-i18n/src/app/i18n/i18n.ts - the configuration of ngx-translate
// -------------------------------------------------------------------------
// ngx-translate is the most widely used translation library with
// Angular. We provide it with:
//   - a DICTIONARY per language (fr.json, en.json), where each text has a
//     KEY ("HOME.HELLO"); the parameters are written {{name}};
//   - a LOADER (TranslateLoader), which returns the dictionary of a
//     language. Ours takes them from the code of the application (import of
//     JSON files); another one could download them;
//   - the starting language (the one stored in localStorage, otherwise that
//     of the browser, otherwise English) and the fallback language.
// In the templates, {{ 'HOME.HELLO' | translate: { name: 'Marie' } }}
// displays the text of the CURRENT language; changing the language updates
// the whole page.
//
// It is, on the client side, the same principle as the NestJS course (dictionaries
// i18n/*.json + helper {{t}}) - but the translation is now done in
// the browser, and changing the language no longer asks anything of the server.
//
// (Angular also has its own system, @angular/localize, which builds ONE
// compiled application per language: the language can then only be changed
// by changing page. ngx-translate, on the other hand, translates at runtime.)
// =========================================================================

import { Provider } from '@angular/core';
import { provideTranslateLoader, provideTranslateService, TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import fr from './fr.json';
import en from './en.json';

export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
export const LANGUAGE_KEY = 'example21.language';

// the loader of the dictionaries
class DictionariesLoader implements TranslateLoader {
  getTranslation(language: string): Observable<TranslationObject> {
    return of(language === 'en' ? en : fr);
  }
}

function initialLanguage(): Language {
  const stored = localStorage.getItem(LANGUAGE_KEY);
  if (stored === 'fr' || stored === 'en') {
    return stored;
  }
  return navigator.language.startsWith('fr') ? 'fr' : 'en';
}

export function provideI18n(): Provider[] {
  return provideTranslateService({
    loader: provideTranslateLoader(DictionariesLoader),
    fallbackLang: 'en', // a key missing in French: the English one is used
    lang: initialLanguage(),
  });
}
  • Lines 29–30: The dictionaries are imported as modules; they are part of the application code. This is made possible by the [resolveJsonModule] option in the [tsconfig.json] file;
  • line 34: the key under which the selected language is stored in [localStorage] (example 20);
  • lines 37–41: the dictionary loader: [ngx-translate] calls it to retrieve a language’s dictionary in the form of an observable. Ours retrieves them from the code; another might download them from the server;
  • lines 43–49: the source language: the one the user last selected, otherwise French if the browser’s language is French, otherwise English;
  • lines 51–57: [provideTranslateService] makes the services of [ngx-translate] available throughout the application. [fallbackLang]: the language used for a key missing from the current dictionary.
// =========================================================================
// 21-i18n/src/main.ts
// -------------------------------------------------------------------------
// provideI18n(): ngx-translate (see app/i18n/i18n.ts).
// registerLocaleData(localeFr): the French rules of the date,
// currency... pipes (English is always available).
// =========================================================================

import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { provideI18n } from './app/i18n/i18n';

registerLocaleData(localeFr);

bootstrapApplication(App, { providers: [provideI18n()] }).catch((error) => console.error(error));
  • line 15: the pipes [date] and [currency] (example 09) know the rules for English; those for French must be registered;
  • line 17: the services for [ngx-translate] are added to the application.

6.1.3. Plurals

[ngx-translate] does not support plurals. A small pipe selects the correct form:

// =========================================================================
// 21-i18n/src/app/i18n/plural.pipe.ts - choosing the plural form
// -------------------------------------------------------------------------
// ngx-translate does not handle plurals. The dictionary gives the
// forms under one same key ("APPOINTMENTS": { "zero", "one", "other" }) and
// this pipe chooses the right one, depending on the number and the language:
//     {{ 'HOME.APPOINTMENTS' | plural: n : language | translate: { n } }}
// Intl.PluralRules knows the rules of each language ("one" or "other" in
// English as in French, but other forms in Polish, in Arabic...).
// =========================================================================

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'plural' })
export class PluralPipe implements PipeTransform {
  transform(key: string, n: number, language: string | null): string {
    if (n === 0) {
      return `${key}.zero`;
    }
    return `${key}.${new Intl.PluralRules(language ?? 'en').select(n)}`;
  }
}
  • Lines 16–21: The pipe receives a key ([HOME.APPOINTMENTS]), a number, and a language; it returns the key in the correct form ([HOME.APPOINTMENTS.one], for example);
  • Line 18: Zero has its own format (“no appointment”);
  • line 20: [Intl.PluralRules], provided by the browser, follows the rules for each language: [one] or [other] in French and English, but other forms in Polish or Arabic. (Note: In French, 1.5 is singular; in English, it is plural.)

6.1.4. The translated page

// =========================================================================
// 21-i18n/src/app/app.ts - a translated page
// -------------------------------------------------------------------------
//   - 'KEY' | translate                    a text;
//   - 'KEY' | translate: { name: ... }     a text with parameters;
//   - date / currency with the language    a date, an amount, in the format
//                                          of the current language;
//   - translate.use('fr')                  changes the language: the whole
//                                          page is translated again.
// TranslateService.currentLang is a SIGNAL: the current language.
// =========================================================================

import { Component, effect, inject, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LANGUAGE_KEY, Language, LANGUAGES } from './i18n/i18n';
import { PluralPipe } from './i18n/plural.pipe';

@Component({
  selector: 'app-root',
  imports: [CurrencyPipe, DatePipe, TranslatePipe, PluralPipe],
  templateUrl: './app.html',
})
export class App {
  private readonly translate = inject(TranslateService);
  protected readonly language = this.translate.currentLang;
  protected readonly languages = LANGUAGES;
  protected readonly count = signal(2);
  protected readonly today = new Date();

  // what the RdvMedecins server would return (see the chapter on the black box)
  protected readonly serverResponses = [
    { statusCode: 409, key: 'ERRORS.LOGIN_TAKEN', params: {} },
    { statusCode: 429, key: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
  ];

  // translate.translate(key): a SIGNAL that follows the current language
  private readonly title = this.translate.translate('APP.TITLE');

  constructor() {
    // on each change of language: we store it, and we update
    // the lang attribute of the page (useful to screen readers, spell checkers)
    effect(() => {
      const language = this.language();
      if (language) {
        localStorage.setItem(LANGUAGE_KEY, language);
        document.documentElement.lang = language;
      }
      document.title = String(this.title());
    });
  }

  protected choose(language: Language): void {
    this.translate.use(language);
  }
}
  • line 21: [TranslatePipe]: the pipe [translate];
  • line 25: [TranslateService]: the main service of [ngx-translate];
  • line 26: [currentLang]: the current language, as a signal;
  • lines 32–35: two error responses, as the server would return them: a key and parameters;
  • line 38: [translate.translate(key)] returns a signal equal to the translation of the key, which follows the current language;
  • lines 43–50: an action executed whenever the language changes: the language is stored (line 46), the page’s [lang] attribute is updated (line 47; this is used by screen readers and spell-checkers), as well as the tab title (line 49);
  • line 54: [use] changes the current language: the entire page is re-translated.
<div class="container py-4" style="max-width: 720px">
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h1 class="h3 mb-0">{{ 'APP.TITLE' | translate }}</h1>
    <div class="btn-group btn-group-sm" role="group" [attr.aria-label]="'APP.LANGUAGE' | translate">
      @for (l of languages; track l) {
        <button class="btn" [class]="language() === l ? 'btn-primary' : 'btn-outline-primary'" (click)="choose(l)">
          {{ l.toUpperCase() }}
        </button>
      }
    </div>
  </div>

  <div class="card p-3 mb-3">
    <p>{{ 'HOME.HELLO' | translate: { name: 'Marie' } }}</p>
    <!-- a parameter computed by another pipe, in the current language -->
    <p>{{ 'HOME.TODAY' | translate: { date: (today | date: 'fullDate' : '' : language() ?? 'en') } }}</p>
    <p>{{ 'HOME.PRICE' | translate: { price: (1234.5 | currency: 'EUR' : 'symbol' : '1.2-2' : language() ?? 'en') } }}</p>
    <!-- plural chooses the key (zero, one, other), translate translates it -->
    <p class="fw-bold">{{ 'HOME.APPOINTMENTS' | plural: count() : language() | translate: { n: count() } }}</p>
    <div class="d-flex gap-2">
      <button class="btn btn-sm btn-outline-success" (click)="count.set(count() + 1)">{{ 'HOME.ADD' | translate }}</button>
      <button class="btn btn-sm btn-outline-secondary" [disabled]="count() === 0" (click)="count.set(count() - 1)">
        {{ 'HOME.REMOVE' | translate }}
      </button>
    </div>
  </div>

  <div class="card p-3">
    <h2 class="h6">{{ 'SERVER.TITLE' | translate }}</h2>
    <p>{{ 'SERVER.EXPLANATION' | translate }}</p>
    <ul class="mb-0">
      @for (r of serverResponses; track r.key) {
        <!-- the key of the server IS a key of the dictionary; params provides its parameters -->
        <li><code>{{ r.statusCode }} {{ r.key }}</code><br>&rarr; {{ r.key | translate: r.params }}</li>
      }
    </ul>
  </div>
</div>
  • line 3: ['APP.TITLE' | translate]: the translation of the key in the current language;
  • line 6: the current language button is highlighted;
  • line 14: a parameterized text: the pipe receives an object that provides the value of each parameter;
  • lines 16–17: the value of a parameter can be calculated by another pipe. The last argument of [date] and [currency] is the language: the date and amount are written in the French or English style;
  • line 19: two chained pipes: [plural] selects the key, and [translate] translates it;
  • line 34: the key returned by the server is translated, along with its parameters.

[npm start 21-i18n]; with a browser set to French, the page is in French:

Image

Click on [EN], then on “Remove one”:

Image

Reload the page: it remains in English; the language has been saved.