@ditsmod/i18n - a module for translating system messages
Tags: ditsmod, nestjs, typescript, node
Published: 2022-08-28 13:20:00

i18n is an abbreviation of the internationalization. The @ditsmod/i18n module provides basic functionality for translating system messages (issued by the [Ditsmod][1] 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][2], although it is more convenient to view it locally, so it is better to clone it first:

```bash
git clone https://github.com/ditsmod/ditsmod.git
cd ditsmod
yarn
yarn boot
```

The example can be run with the command:

```bash
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:

```text
└── 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:

```ts
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][3] (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:

```ts
const dict = this.dictService.getDictionary(CommonDict);
dict.hello('World');
```

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

```ts
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`:

```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:

```ts
/**
 * 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:

```ts
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:

```ts
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:

```ts
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:

```text
└── 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:

```ts
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:

```ts
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 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:

```ts
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`:

```ts
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:

```ts
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:

```ts
// ...
@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.


[1]: https://ditsmod.github.io/en/
[2]: https://github.com/ditsmod/ditsmod
[3]: https://en.wikipedia.org/wiki/ISO_639