DEV Community

Cover image for Typed translations in Angular
Noda
Noda

Posted on

Typed translations in Angular

How to Improve Angular Application Build Quality and Enhance Key Verification with ngx-translate or transloco

By Maksim Dolgikh, Senior Front End Developer at Noda.

Introduction

Supporting internationalization is one of the challenging topics in front-end development, everyone always adds only keys and values for the primary locale, usually English (en or en-gb), leaving translations for other languages for later.

We encountered this issue on one of our projects while implementing localization.

In the initial version, we did not configure the localization settings, completed the build, and obtained the result.

broken-i18n-tokens

Translations by token, in case of failure, were replaced with the token name.

The hotfix for the problem is to use a fallback language (fallbackLang) when resolving translations by token.

brokent-i18n-fallbackLang

The fallbackLang solves the issue of displaying text so as not to confuse the user, but there's still a need to monitor the presence of keys, and also involve QA to detect problems.

Many who use runtime translations fully understand the problem of, "how can you ensure that the key-token is correct and will find the corresponding translation for the current locale at browser runtime?"

I too wondered about this, but all I managed to find was an article from October 21, 2018. Typed translations in Angular.

The solution was excellent for that time, but the author uses the object directly, and native pluralization through js with the presence of ICU seems excessive.

Undoubtedly, this solution could have been improved, for instance, by making the TRANSLATION token a stream and adding an aggregator service with translations upon language change, and so on. But then again, that's a whole different story.

ESLINT - i18n-json

The situation is partly saved by the eslint plugin eslint-plugin-i18n-json, which checks the identity of the keys in json files, but it's also not enough for reliability.

Because it's a one-sided check, and it doesn't guarantee that the developer, using the current translation base, will specify the correct key, and QA will notice it if something goes wrong.

We will need to install eslint, commitlint to check changes in json, and also integrate it into pipelines if we have translations embedding from external sources.

My Proposal

What if I told you that all these checks are unnecessary?

To ensure the consistency of translations under a locale, forget about fallbackLang and missingKeyHandler, and no longer worry about the correctness of the key, can it be achieved solely with typescript?

This solution is suitable both for current implementations on ngx-translate or transloco and for new ones.

So, what requirements should the solution meet?

  • The application build fails if a key is missing in one of the translations.
  • Developers are notified of errors with an incorrect key, and there are hints about the available list of keys.
  • Ensure scalability by adding new translations by locale, without direct interaction with the code, but provide all checks.
  • Current key translations for ngx-translate and transloco work as before. They can be parameterized, and they support ICU.
  • Translations can be used in libraries, and they can be published as self-sufficient packages.

Secondary:

  • QA, through the browser console, can understand which element interacts with which key.
  • Exclude switching to a locale if it's not on the list of available ones.

Solution

The demonstration repository consists of:

  • assets - a directory with a subdirectory i18n, where translation files are stored.
  • libs - a set of libraries for:

    • ui-actions and ui-layouts - a set of demo-libs.
    • i18n-wrapper - contains entities for typing the transloco service.
    • typed-transloco - a typed version of transloco with its translation file and based on i18n-wrapper.
  • src - directory of the host application.

  • scripts - directory for service scripts.

repo-structure

For simplicity and convenience, I decided to base my solution on the library @ngneat-transloco, which is common for the two repository libraries.
It provides a unified translation file and a service with which they interact.

In the case of publishing UI libraries, this library will also be published and will serve as a peer dependency.

This solution is more suitable for large monorepositories where translations are managed through a core library with assets/i18n. Nothing prevents my solution from being applied to ngx-translate, where there is a mechanism for service inheritance or separation.

1. Library i18n-wrapper

This library will contain two directories:

  • transloco - a directory for transloco wrappers
  • types - common types for typing the translation file.

i18n-wrapper-structure

Typing tokens for translation

We are accustomed to specifying a translation token as a string in the form of keys separated by a dot. Example: lib.scope.module.component.somekey.

I wanted to maintain this approach and create a type that calculates all possible keys from our unified translation file.

I decided to base such a type on the recursive type presented in an article by @bytefer.

Unlike the universal version in the article, I will trim this recursive type for our solution, so it provides only the final paths through a dot to the primitive.

type TAddPrefix<Prefix extends string, Key extends string> = [Prefix] extends [never]
 ? `${Key}`
 : `${Prefix}.${Key}`;


export type TObjectKeyPaths<T extends Record<string, any>, Prefix extends string = never> = {
 [P in string & keyof T]: T[P] extends object
   ? TObjectKeyPaths<T[P], TAddPrefix<Prefix, P>>
   : T[P] extends string
     ? TAddPrefix<Prefix, P>
     : never;
}[string & keyof T];
Enter fullscreen mode Exit fullscreen mode

Having a recursive type to retrieve translation paths, all that's left for us to do is to create a type that transforms the readonly type from i18n.db.ts to a key map for each locale, ensuring synchronization between languages.

export type TTranslatePath<
 DB extends object,
 Langs extends keyof DB = keyof DB
> = TObjectKeyPaths<DB[Langs]>
Enter fullscreen mode Exit fullscreen mode

With these two types, we've obtained the CORE part of our typing, which addresses 80% of the issues from the posed problem, which can already be applied to different parts of the project, not just to localization.

Only 20% remains - a typed wrapper for transloco.

There will be only three wrappers, and all of them are abstract:

  • AbstractTranslocoConfigService - a service regulating the list of available languages when providing settings for TRANSOLO_CONFIG. It also implements the TranslocoLoader interface to load our translation file.
  • AbstractTranslocoService - a service wrapper over TranslocoService, which offers a similar list of methods from TranslocoService, but at the same time narrows down the types of available languages and keys to those available from i18n.db.ts.
  • AbstractTranslocoComponent - one of the entities that will inject translations into the template based on the provided token (similar to pipe and directive).

AbstractTranslocoConfigService

As I mentioned earlier, it is necessary to provide functions for the TRANSLOCO_CONFIG and TRANSLOCO_LOADER tokens when values are delivered through a factory.

This service is straightforward and will accept one generic from the type of our translation file for typing the available list of languages.

Since we know the available list of languages, and we get the default language in the constructor, for the method providing the config, we can exclude defaultLang, availableLangs, and fallbackLang, leaving settings for transloco.

export type TTypedTranslocoConfig = Partial<Omit<TranslocoConfig, 'defaultLang' | 'availableLangs' | 'fallbackLang'>>;
Enter fullscreen mode Exit fullscreen mode

Result

export abstract class AbstractTranslocoConfigService<
   i18nDb extends Record<string, Translation>,
   Lang extends keyof i18nDb = keyof i18nDb,
> implements TranslocoLoader {
   public readonly AVAILABLE_LANGS: Lang[]


   protected constructor(
       public readonly DB: i18nDb,
       public readonly DEFAULT_LANG: Lang,
   ) {
       this.AVAILABLE_LANGS = Array.from(
           new Set<Lang>([
               DEFAULT_LANG,
               ...Object.keys(DB) as Lang[],
           ]),
       );
   }


   public getProvidedConfig(config: TTypedTranslocoConfig): TranslocoConfig {
       const patchedConfig: Partial<TranslocoConfig> = {
           ...config,
           defaultLang: this.initializeDefaultLanguage() as string,
           availableLangs: this.AVAILABLE_LANGS as string[],
           fallbackLang: this.DEFAULT_LANG as string
       }


       return translocoConfig(patchedConfig);
   }


   private initializeDefaultLanguage(): Lang {
       const browserLanguage = getBrowserLang() as Lang | undefined;


       if (!browserLanguage) {
           return this.DEFAULT_LANG
       }


       return this.isAvailableLang(browserLanguage) ? browserLanguage : this.DEFAULT_LANG;
   }




   public isAvailableLang(lang: Lang | string): lang is Lang {
       return this.AVAILABLE_LANGS.includes(lang as Lang)
   }


   public getTranslation(lang: string): Observable<Translation> {
       return of(this.DB[lang])
   }
}
Enter fullscreen mode Exit fullscreen mode

AbstractTranslocoService

This service doesn't contain any particular logic and serves as a typed intermediary between entities wanting to use the TranslocoService.

For simplicity and to minimize code, I've only added methods for language management.

The only distinctive feature of this service from the native TranslocoService is the filtering of languages based on the AbstractTranslocoConfigService to avoid setting an unavailable language.

export abstract class AbstractTranslocoService<
   i18nDb extends Record<string, Translation>,
   Lang extends keyof i18nDb = keyof i18nDb,
> {


   protected constructor(
       protected readonly translocoService: TranslocoService,
       protected readonly translocoConfigService: AbstractTranslocoConfigService<i18nDb, Lang>
   ) {
   }


   public getActiveLang(): Lang {
       return this.translocoService.getActiveLang() as Lang;
   }


   public setActiveLang(candidate: Lang): void {
       this.translocoService.setActiveLang(this.checkLanguage(candidate) as string);
   }


   public getDefaultLang(): Lang {
       return this.translocoService.getDefaultLang() as Lang;
   }


   public setDefaultLang(candidate: Lang): void {
       this.translocoService.setDefaultLang(this.checkLanguage(candidate) as string);
   }


   public getAvailableLangs(): Lang[] {
       return this.translocoService.getAvailableLangs() as Lang[];
   }


   private checkLanguage(language: Lang): Lang {


       if (this.translocoConfigService.isAvailableLang(language)) {
           return language as Lang
       }


       const browserLanguage: string | undefined = getBrowserLang();
       if (browserLanguage && this.translocoConfigService.isAvailableLang(browserLanguage)) {
           return browserLanguage;
       }


       return this.getActiveLang() || this.translocoConfigService.DEFAULT_LANG;
   }
}
Enter fullscreen mode Exit fullscreen mode

AbstractTranslocoComponent

I've chosen to implement this through a component, and this choice is deliberate for several reasons:

  • It allows us not to declare functions in the template, which could be restricted by eslint rules.
  • There's an option to detach the component from the component tree for optimization when switching languages.
  • For QA purposes, there's the possibility to bind our token to an element using HostBinding, allowing for identification of which token is being used in a particular location.
  • Auto-suggestions for available tokens are provided.

Cons:

  • There's a need to specify the complete key from the translation file.
  • It's impossible to pass the value from one component to others that accept text only in the string format and don't allow customization via <ng-content> or TemplateRef.

Fortunately, these drawbacks are addressed by creating wrappers for the translocoPipe and translocoDirective.

To reduce coupling, I will introduce an interface named ISelectTranslateService, which will regulate the service implementation for translations based on parameters.

export interface ISelectTranslateService<
   i18nDB extends Record<string, Translation>,
   Lang extends keyof i18nDB = keyof i18nDB> {
   selectTranslate$: (
       path: TTranslatePath<i18nDB>,
       params?: HashMap,
       lang?: Lang
   ) => Observable<string>
}
Enter fullscreen mode Exit fullscreen mode

Result

@Directive()
export abstract class AbstractTranslocoComponent<
 i18nDb extends Record<string, Translation>, Lang extends keyof i18nDb = keyof i18nDb,
> implements OnChanges {
 private readonly update$: ReplaySubject<void> = new ReplaySubject<void>(1);


 public translation$: Observable<string> = this.update$.asObservable().pipe(
     switchMap(() => this.getCurrentTranslation$())
 );


 @Input()
 public key!: TTranslatePath<i18nDb>


 @Input()
 public params?: HashMap;


 @Input()
 public lang?: Lang;


 protected constructor(
   protected service: ISelectTranslateService<i18nDb, Lang>
 ) {
 }


 public ngOnChanges(): void {
   this.update();
 }


 public update(): void {
   this.update$.next();
 }


 private getCurrentTranslation$(): Observable<string> {
   return this.service.selectTranslate$(
       // @ts-expect-error: not be infinite
       this.key,
       this.params,
       this.lang
   )
 }
}
Enter fullscreen mode Exit fullscreen mode

(advanced implementation with detach and HostBinding)

2. Library typed-transloco

Unified Translation Database

Firstly, I'd like to move away from the usual practice of placing translations under each locale in json to a single ts file.

  • assets/i18n/en.json
{
 "actions": {
   "back": "Back",
   "confirm": "Are you sure?",
   "new": "New"
 },
 "layouts": {
   "about": "About",
   "account": "Account",
   "app_store": "App Store"
 }
}
Enter fullscreen mode Exit fullscreen mode
  • assets/i18n/de.json
{
 "actions": {
   "back": "Zurück",
   "confirm": "Bist du sicher?",
   "new": "Neu"
 },
 "layouts": {
   "about": "Um",
   "account": "Konto",
   "app_store": "Appstore"
 }
}
Enter fullscreen mode Exit fullscreen mode

It exports a single object with all translations under each locale.

unifier-schema

  • i18n.db.ts
export const TRANSLATE_DB = {
 "de": {
   "actions": {
     "back": "Zurück",
     "confirm": "Bist du sicher?",
     "new": "Neu"
   },
   "layouts": {
     "about": "Um",
     "account": "Konto",
     "app_store": "Appstore"
   }
 },
 "en": {
   "actions": {
     "back": "Back",
     "confirm": "Are you sure?",
     "new": "New"
   },
   "layouts": {
     "about": "About",
     "account": "Account",
     "app_store": "App Store"
   }
 }
};
Enter fullscreen mode Exit fullscreen mode

Essentially, yes, I'm moving away from lazily loaded translations towards synchronous loading and predictable typing.

Certainly, you can import JSON files into TS, but then you'd have to configure tsconfig.json for resolveJsonModule. Also, for personal reasons, I find this approach suboptimal if there's a process for exporting a single translation file from the project.

Typed Entities

With the presence of a single translation file and abstract wrappers, we can implement typed versions for our translations:

  • TypedTranslocoConfigService
@Injectable()
export class TypedTranslocoConfigService extends AbstractTranslocoConfigService<typeof TRANSLATE_DB> {
   constructor() {
       super(TRANSLATE_DB, 'en');
   }
}
Enter fullscreen mode Exit fullscreen mode
  • TypedTranslocoService
@Injectable()
export class TypedTranslocoService extends AbstractTranslocoService<typeof TRANSLATE_DB>{
   constructor(
       translocoService: TranslocoService,
       typedTranslocoConfigService: TypedTranslocoConfigService
   ) {
       super(translocoService, typedTranslocoConfigService);
   }
}
Enter fullscreen mode Exit fullscreen mode
  • TypedTranslocoComponent
@Component({
 selector: 'typed-transloco-component',
 standalone: true,
 imports: [CommonModule],
 template: '{{ translation$ | async }}',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TypedTranslocoComponent extends AbstractTranslocoComponent<typeof TRANSLATE_DB>{
 constructor(
     public typedTranslocoService: TypedTranslocoService
 ) {
   super(typedTranslocoService);
 }
}
Enter fullscreen mode Exit fullscreen mode

TypedTranslocoModule

It will also be required to declare all the created services and provide the necessary values when we want to use translations.

@NgModule({
   imports: [TranslocoModule],
   providers: [TranslocoService]
})
export class TypedTranslocoModule {
   static forRoot(config: TTypedTranslocoConfig = {}): ModuleWithProviders<TypedTranslocoModule> {
       return {
           ngModule: TypedTranslocoModule,
           providers: [
               TypedTranslocoService,
               TypedTranslocoConfigService,
               {
                   provide: TRANSLOCO_LOADER,
                   useExisting: TypedTranslocoConfigService,
               },
               {
                   provide: TRANSLOCO_CONFIG,
                   deps: [TypedTranslocoConfigService],
                   useFactory: (configService: TypedTranslocoConfigService) => {
                       return configService.getProvidedConfig(config)
                   }
               }
           ]
       }
   }
}
Enter fullscreen mode Exit fullscreen mode

3. Integration into UI libraries

The main objective of this article is to demonstrate a solution for typing translations, so the examples for these libraries will be simple and contain minimal logic.

For this purpose, a showcase component was created in each of them.

Both components, conditionally, display a list of available components from the library for the user, similar to the storybook.

I only interact with the component template and will create one element that supports typed localization for now.

i18n-autocomplete-token

Since we passed the computed type from TRANSLATE_DB when creating our component, the code editor can already suggest to us which available tokens we have for translation.

This also works for the list of available languages.

i18n-autocomplete-lang

When attempting to specify an incorrect token, the editor will highlight a warning indicating that we have specified the key incorrectly. We will deliberately leave this issue uncorrected.

i18n-autocomplete-token-error

Result

  • ui-actions library
<button>
   <typed-transloco-component key="actions.back"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="actions.confirm"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="actions.new-wrong"></typed-transloco-component>
</button>
Enter fullscreen mode Exit fullscreen mode
  • ui-layouts library
<button>
   <typed-transloco-component key="layouts.app_store"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="layouts.account"></typed-transloco-component>
</button>


<button>
   <typed-transloco-component key="layouts.about"></typed-transloco-component>
</button>
Enter fullscreen mode Exit fullscreen mode

Using libraries in an application

You can't showcase the localization in libraries without using a demo application. It will be small and contain minimal code.

Displays our two components from the libraries, as well as switches the language through the TypedTranslocoService.

@Component({
 selector: 'app-root',
 template: `
   <lib-actions-showcase></lib-actions-showcase>
   <lib-layouts-showcase></lib-layouts-showcase>
   <div>
     <button (click)="onChangeLang('en')">EN</button>
     <button (click)="onChangeLang('de')">DE</button>
   </div>
 `,
 styles: [`:host {display: flex; flex-direction: column; gap: 8px}`]
})
export class AppComponent {
 constructor(
     private typedTranslocoService: TypedTranslocoService
 ) {}


 public onChangeLang($event: any): void {
   console.log('lang: ', $event)
   this.typedTranslocoService.setActiveLang($event)
 }
}
Enter fullscreen mode Exit fullscreen mode

In addition to importing the library components, the localization module is imported with forRoot to register all necessary services.

When trying to launch our application, we encounter an error 🔥.

build-i18n-error

This is the result we've been striving for throughout the entire article - we catch errors from incorrect tokens and ensure typing.

Let's fix the error 🚀

i18n-autocomplete-token-fixed

And let's rebuild the application. It successfully compiled 🎉🎉🎉, and now we can switch to the browser to see the final result.

And our localization works.

runtime-i18n-showcase

Final result

final-result

Repository

Next Article

In the next article, I'll discuss how to eliminate the need to synchronize keys between translation maps across locales by implementing "file replacement" for libraries. I'll also showcase a fully automated system for localizing an application based on a single translation map for the desired locales through CI.


About Noda

Noda is a global, multi-currency open banking solution for seamless business transactions. It currently operates with 1,650 banks across 28 countries, encompassing 283 bank brands with over 30,000 branches.

Noda enables merchants to receive direct bank payments from eCustomers via Open Banking as an alternative to cards. Merchants can implement Open Banking payments quickly via Noda API, making use of their intuitive UX and lower fees.

Top comments (2)

Collapse
 
flornkm profile image
Florian Kiem

Super nice article Maksim! The example with transloco is great.
I'm working at inlang and we are building an ecosystem to globalize apps, websites, and more.
Currently, we're working on a new i18n library that is typesafe, has a compiler that emits message functions, and introduces the concept of these functions being tree-shakable, leading to auto optimization by the bundler.

It's usable with all frameworks – feel free to give it a try: inlang.com/m/gerre34r/library-inla...

Collapse
 
noda profile image
Noda

Thank you!