@ditsmod/i18n - a module for translating system messages
Костя Третяк
Posted on August 28, 2022
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
The example can be run with the command:
yarn start15
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
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}!`;
}
}
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');
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}!`;
}
}
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,
// ...
}
}
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!`;
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;
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],
// ...
];
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),
// ...
];
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
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),
];
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 {}
As you can see, each module containing a translation must:
- import
I18nModule
; - to the
providersPerMod
array, add a multi-provider containing theI18N_TRANSLATIONS
token and content with theTranslations
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 theprovidersPerMod
array; - you can optionally pass the
I18N_TRANSLATIONS
token to theexports
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 exportI18N_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 {}
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;
}
}
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;
}
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 {}
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.
Posted on August 28, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.