@ditsmod/i18n - a module for translating system messages

kostyatretyak

Костя Третяк

Posted on August 28, 2022

@ditsmod/i18n - a module for translating system messages

i18n is an abbreviation of the internationalization. The @ditsmod/i18n module provides basic functionality for translating system messages (issued by the Ditsmod application at runtime) and provides the ability to easily extend dictionaries. In fact, you use ordinary services as dictionaries for translation, so the text for translation can be taken both from TypeScript files and from databases. The work of @ditsmod/i18n is designed so that each current module can have its own translation, and that the translation of any imported module can be changed or supplemented.

You can view the code with examples of the use of @ditsmod/i18n in the Ditsmod repository, although it is more convenient to view it locally, so it is better to clone it first:

git clone https://github.com/ditsmod/ditsmod.git
cd ditsmod
yarn
yarn boot
Enter fullscreen mode Exit fullscreen mode

The example can be run with the command:

yarn start15
Enter fullscreen mode Exit fullscreen mode

Directory structure

In the examples/15-i18n example, there are two modules, each of which has something like the following directory structure with translation files:

└── modulename
    ├── ...
    ├── locales
    │   ├── current
    │   │   ├── _base-en
    │   │   ├── de
    │   │   ├── fr
    │   │   ├── pl
    │   │   ├── uk
    │   │   └── index.ts
    │   └── imported
    │       ├── one
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       ├── two
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       └── index.ts
Enter fullscreen mode Exit fullscreen mode

This is the recommended directory structure. As you can see, each module has a translation in the locales folder, which contains two folders:

  • current - translation for the current module;
  • imported - changed or supplemented translation for imported modules.

Moreover, only the current folder contains the _base-en folder, which contains basic dictionaries (in this case - in English), from which dictionaries with translations into other languages are branched. An underscore character is used in the name so that _base-en is always above other folders.

Base and extended classes with translations

As already mentioned, dictionaries are ordinary services:

import { Dictionary, ISO639 } from '@ditsmod/i18n';
import { injectable } from '@ts-stack/di';

@injectable()
export class CommonDict implements Dictionary {
  getLng(): ISO639 {
    return 'en';
  }
  /**
   * Hi, there!
   */
  hi = `Hi, there!`;
  /**
   * Hello, ${name}!
   */
  hello(name: string) {
    return `Hello, ${name}!`;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a basic dictionary with English localization. In this case, it has the name CommonDict, but it is not necessary to place the entire translation in one class, you can use other classes, for example, ErrorDict, EmailDict, etc.

Each basic dictionary must implement the Dictionary interface, which has a single requirement - that the dictionary has a getLng() method that returns the abbreviation of the language name according to the standard ISO 639 (for example, abbreviations for English and Ukrainian languages ​​- en, uk).

Why does the method, and not the property, return the abbreviation of the language names?
The point is that in JavaScript, a class property cannot be viewed until an instance of this class is made. But the getLng() method can be easily viewed through YourClass.prototype.getLng(). This allows you to get statistics of available translations even before using dictionaries.

It is recommended to name each service class with the ending *Dict, and the file with the ending *.dict.ts. In addition, the class name of the base dictionary must not contain locales, and that is why in this case the class is not named CommonEnDict, but CommonDict. This is recommended because the base dictionary class will be used to translate to any other available language. For example, in code the following expression can actually return a translation to any language, despite using the underlying dictionary class as a token:

const dict = this.dictService.getDictionary(CommonDict);
dict.hello('World');
Enter fullscreen mode Exit fullscreen mode

Each dictionary class containing a translation must extend the base dictionary class:

import { ISO639 } from '@ditsmod/i18n';
import { injectable } from '@ts-stack/di';

import { CommonDict } from '@dict/second/common.dict';

@injectable()
export class CommonUkDict extends CommonDict {
  override getLng(): ISO639 {
    return 'uk';
  }

  override hello(name: string) {
    return `Привіт, ${name}!`;
  }
}
Enter fullscreen mode Exit fullscreen mode

At a minimum, every translation dictionary should override the getLng() method. For more strict control of dictionaries with translations, it is recommended to use TypeScript 4.3+, as well as the following setting in tsconfig.json:

{
  "compilerOptions": {
    "noImplicitOverride": true,
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case, TypeScript will require the child class to add the override keyword before each method or property from the parent class. This improves the readability of the child class and prevents a mistake in the name of the method or property. If you create a method with an incorrect name, such as helo instead of hello, and mark it as override, TypeScript will issue a warning that no such method exists in the parent class. The same scenario will work if the previously written method has been removed from the parent class.

As you can see, the class names of dictionaries with translations already contain the locale CommonUkDict - this is a dictionary with Ukrainian localization. And since this dictionary extends the base dictionary class, all missing translations will be rendered in the language of the base dictionary. In this case, the base dictionary CommonDict has this property:

/**
 * Hi, there!
 */
hi = `Hi, there!`;
Enter fullscreen mode Exit fullscreen mode

And the CommonUkDict dictionary does not have a translation of this phrase, so when requesting localization into the Ukrainian language, the English version from the base class will be used.

Note that each property or method that is directly used for translation is commented out with the template of the phrase they will return. This adds convenience to using the dictionary in your application code, because your IDE will show these comments:

const dict = this.dictService.getDictionary(CommonDict);
dict.hi;
Enter fullscreen mode Exit fullscreen mode

In this case, when hovering over dict.hi, the IDE will show Hi, there!.

Collection of dictionaries in groups in the current folder

Let me remind you that the current folder contains dictionaries with translations for the current module. These dictionaries must be collected in a single array in the index.ts file:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict';
import { ErrorDict } from '@dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict';
// ...

export const current: DictGroup[] = [
  [CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict],
  [ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict],
  // ...
];
Enter fullscreen mode Exit fullscreen mode

As you can see, groups of dictionaries are transferred in the array, where the class with the base dictionary must always go first. In this case, two groups of dictionaries with base classes CommonDict and ErrorDict are transferred. It is not allowed to mix dictionaries from different groups. If you mix dictionaries from different groups, TypeScript won't be able to tell you about it, so it's recommended to use the getDictGroup() function for better control of class types:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict';
import { ErrorDict } from '@dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict';
// ...

export const current: DictGroup[] = [
  getDictGroup(CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict),
  getDictGroup(ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict),
  // ...
];
Enter fullscreen mode Exit fullscreen mode

Collection of dictionaries in groups in the imported folder

Let me remind you that the imported directory contains dictionaries with translations for imported modules, and note that it does not contain base dictionaries (it does not have a _base-en folder), since the base dictionaries of imported modules are located in these modules in the current directories:

└── modulename
    ├── ...
    ├── locales
    │   ├── current
    │   │   ├── _base-en
    │   │   ├── de
    │   │   ├── fr
    │   │   ├── pl
    │   │   ├── uk
    │   │   └── index.ts
    │   └── imported
    │       ├── one
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       ├── two
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       └── index.ts
Enter fullscreen mode Exit fullscreen mode

The directory imported contains individual folders of each module, for which you need to supplement or rewrite the translation. In this case, the imported folder contains additions or translation rewrites for modules one and two. Collection of groups of dictionaries in the imported folder is similar to how it is done in current, but the base dictionaries are taken from external modules:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/first/common.dict'; // A basic dictionary from an external module from the current folder
import { CommonUkDict } from './first/uk/common-uk.dict'; // Addition of translation for an external module from the imported folder

export const imported: DictGroup[] = [
  getDictGroup(CommonDict, CommonUkDict),
];
Enter fullscreen mode Exit fullscreen mode

In this case, the basic dictionary CommonDict is imported from FirstModule, and the addition of the Ukrainian translation is taken in the current module from the imported folder.

Transfer of translations to the module

Now it remains to transfer groups of dictionaries to the module:

import { featureModule } from '@ditsmod/core';
import { I18nModule, I18nOptions, I18N_TRANSLATIONS, Translations } from '@ditsmod/i18n';

import { current } from './locales/current';
import { imported } from './locales/imported';

const translations: Translations = { current, imported };
const i18nOptions: I18nOptions = { defaultLng: 'uk' };

@featureModule({
  imports: [
    I18nModule,
    // ...
  ],
  providersPerMod: [
    { provide: I18N_TRANSLATIONS, useValue: translations, multi: true },
    { provide: I18nOptions, useValue: i18nOptions },
  ],
  exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
Enter fullscreen mode Exit fullscreen mode

As you can see, each module containing a translation must:

  • import I18nModule;
  • to the providersPerMod array, add a multi-provider containing the I18N_TRANSLATIONS token and content with the Translations data type, where dictionary groups for both the current and the imported module are transferred;
  • the provider with the token I18nOptions can be transferred to the providersPerMod array;
  • you can optionally pass the I18N_TRANSLATIONS token to the exports array, if you want the base dictionaries from the current module to be available to external modules. At the same time, please note that such an export is only necessary if you want to directly use the base dictionaries, that is, in the code of your program, you import them. And if you export a certain service that internally uses base dictionaries (encapsulates their use), then you do not need to export I18N_TRANSLATIONS.

If you use the i18nProviders().i18n() helper, you can slightly reduce the amount of code:

import { featureModule } from '@ditsmod/core';
import { I18nModule, I18nProviders } from '@ditsmod/i18n';

import { current } from './locales/current';
import { imported } from './locales/imported';

@featureModule({
  imports: [
    I18nModule,
    // ...
  ],
  providersPerMod: [
    ...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk' }),
  ],
  exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
Enter fullscreen mode Exit fullscreen mode

As the first argument for i18nProviders().i18n(), an object of type Translations is passed, options of type I18nOptions are passed in the second place. Note that the helper is preceded by an ellipsis, as it returns an array to be merged with the other providers in the providersPerMod array.

Use of dictionaries with translation

To use dictionaries, you need to use DictService:

import { injectable } from '@ts-stack/di';
import { DictService } from '@ditsmod/i18n';

import { CommonDict } from '@dict/first/common.dict';

@injectable()
export class FirstService {
  constructor(private dictService: DictService) {}

  countToThree() {
    const dict = this.dictService.getDictionary(CommonDict);
    return dict.countToThree;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, base dictionary classes are always used as a token to search for the required group of dictionaries. In this case, this code will work if the base dictionary contains the countToThree property. It will output the required translation if there is a dictionary with the corresponding translation in the dictionary group CommonDict. You can specify the locale in the second argument:

countToThree() {
  const dict = this.dictService.getDictionary(CommonDict, 'uk');
  return dict.countToThree;
}
Enter fullscreen mode Exit fullscreen mode

But in most cases, the language is selected through an HTTP request. By default, DictService takes the locale from the lng parameter in this.req.queryParams, but you can change the name of this parameter by passing the lngParam option:

// ...
@featureModule({
  // ...
  providersPerMod: [
    ...new I18nProviders().i18n({ current, imported },  { defaultLng: 'uk', lngParam: 'locale' }),
  ],
})
export class SecondModule {}
Enter fullscreen mode Exit fullscreen mode

Note that DictService is declared at the HTTP request level, so you will not be able to use this service in other services declared at higher levels (at the application or module level). If you need a higher-level service, use DictPerModService, which is actually a parent class for DictService with an almost identical API.

Arbitrary definition of the request language

Although the default value of the query language is determined via this.req.queryParams, you can easily change the logic of determining the query language, for example, by accept-language headers. To do this, it is enough to change the dictService.lng getter.

If you cloned the repository, you will find an example of MyDictService in the examples/15-i18n/src/app/third/third.module.ts module. This service extends DictPerModService and only overwrites the getter of mydictService.lng and the setter is also overwritten so that mydictService.lng can be edited. Well, after your own implementation of the definition of the language of the request, of course, you need to add the new service providersPerReq in the module.

💖 💪 🙅 🚩
kostyatretyak
Костя Третяк

Posted on August 28, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related