DEV Community

Cover image for Dynamic translations in Angular made possible
Michael M.
Michael M.

Posted on • Updated on

Dynamic translations in Angular made possible

A practical guide to implement lazy-loaded translations

If you have ever dealt with internationalization (or “i18n” for short) in Angular or is about to implement it, you may stick with the official guide which is awesome, use third-party packages that might be hard to debug or choose an alternative path which I will describe below.

One of the common pitfalls when using i18n are large translation files size and inability to split them in order to hide parts of your application from prying eyes. Some solutions like Angular built-in implementation are really powerful and SEO compatible but require a lot of preparation and do not support switching languages on the fly in development mode (which was causing troubles at least in version 9); other solutions like ngx-translate require you to install several packages and still don’t support splitting up a single language (update: in fact, ngx-translate supports this).

While there is no “magic wand” out there for this complex feature that supports everything and fits everyone, here is another way of implementing translations that might fit your needs.
Enough with the introduction, I promised this would be a practical guide, so let’s jump straight into it.


Preparing the basics

The first step is to create a type for languages that will be used across the app:

export type LanguageCode = 'en' | 'de';
Enter fullscreen mode Exit fullscreen mode

One of the loved Angular features is Dependency Injection that does a lot for us — let’s utilize it for our needs. I would also like to spice things up a little by using NgRx for this guide but if you don’t use it in your project, feel free to replace it with a simple BehaviorSubject.

As an optional step that will make further development with NgRx easier, create a type for DI factories:

export type Ti18nFactory<Part> = (store: Store) => Observable<Part>;
Enter fullscreen mode Exit fullscreen mode

Creating translation files

General strings

Suppose we have some basic strings that we’d like to use across the app. Some simple yet common things that are never related to a specific module, feature or library, like “OK” or “Back” buttons.
We will place these strings in “core” module and start doing so with a simple interface that will help us not to forget any single string in out translations:

export interface I18nCore {
  errorDefault: string;
  language: string;
}
Enter fullscreen mode Exit fullscreen mode

Just to be clear, this interface does not guarantee that all the strings will be actually translated but the TypeScript compiler (and your IDE) will raise an error “TS2741” if you forget to include any string in your “lang” files.

Moving on to the implementation for the interface and for this snippet it’s vitally important that I provide an example file path which in this case would be libs/core/src/lib/i18n/lang-en.lang.ts:

export const lang: I18nCore = {
  errorDefault: 'An error has occurred',
  language: 'Language',
};
Enter fullscreen mode Exit fullscreen mode

To reduce code duplication and get the most out of the development process, we’ll also create a DI factory. Here’s a working example utilizing NgRx (again, this is completely optional, you may use BehaviorSubject for this):

export const I18N_CORE =
  new InjectionToken<Observable<I18nCore>>('I18N_CORE');

export const i18nCoreFactory: Ti18nFactory<I18nCore> =
  (store: Store): Observable<I18nCore> => 
    (store as Store<LocalePartialState>).pipe(
      select(getLocaleLanguageCode),
      distinctUntilChanged(),
      switchMap((code: LanguageCode) =>
        import(`./lang-${code}.lang`)
          .then((l: { lang: I18nCore }) => l.lang)
      ),
    );

export const i18nCoreProvider: FactoryProvider = {
  provide: I18N_CORE,
  useFactory: i18nCoreFactory,
  deps: [Store],
};
Enter fullscreen mode Exit fullscreen mode

Obviously, the getLocaleLanguageCode selector will pick the language code from Store.

Don’t forget to include translation files into your compilation as they are not being referenced directly thus will not be automatically included. For that, locate the relevant “tsconfig” (the one that lists “main.ts”) and add the following to the “include” array:

"../../libs/core/src/lib/i18n/*.lang.ts"
Enter fullscreen mode Exit fullscreen mode

Note that the file path here includes a wildcard so that all your translations will be included at once. Also, as a matter of taste, I like to prefix similar files which pretty much explains why the example name ([prefix]-[langCode].lang.ts) looks so weird.

Module-specific strings

Let’s do the same for any module, so we can see how translations will be loaded separately in the browser. To keep it simple, this module would be named “tab1”.

Again, start with the interface:

export interface I18nTab1 {
  country: string;
}
Enter fullscreen mode Exit fullscreen mode

Implement this interface:

export const lang: I18nTab1 = {
  country: 'Country',
};
Enter fullscreen mode Exit fullscreen mode

Include your translations into compilation:

"../../libs/tab1/src/lib/i18n/*.lang.ts"
Enter fullscreen mode Exit fullscreen mode

And optionally create a DI factory which would look literally the same as previous but with another interface.


Providing translations

I prefer to reduce the amount of providers so “core” translations will be listed in AppModule only:

providers: [i18nCoreProvider],
Enter fullscreen mode Exit fullscreen mode

Any other translation should be provided in the relevant modules only — either in lazy-loaded feature modules or, if you follow the SCAM pattern, in component modules:

@NgModule({
  declarations: [TabComponent],
  imports: [CommonModule, ReactiveFormsModule],
  providers: [i18nTab1Provider],
})
export class TabModule {}
Enter fullscreen mode Exit fullscreen mode

Also note the elegance of utilizing pre-made FactoryProviders instead of adding objects here.

Inject the tokens in a component.ts:

constructor(
  @Inject(I18N_CORE)
  public readonly i18nCore$: Observable<I18nCore>,
  @Inject(I18N_TAB1)
  public readonly i18nTab1$: Observable<I18nTab1>,
) {}
Enter fullscreen mode Exit fullscreen mode

And finally, wrap component.html with ng-container and a simple ngIf statement:

<ng-container *ngIf="{
    core: i18nCore$ | async,
    tab1: i18nTab1$ | async
  } as i18n">
    <p>{{ i18n.core?.language }}</p>
    <p>{{ i18n.tab1?.country }}: n/a</p>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

Checking out the result

Let’s run this and see if this actually works and more importantly how exactly would these translations be loaded. I created a simple demo app consisting of two lazy-loaded Angular modules, so you may clone and experiment with it. But for now, here are the actual screenshots of DevTools:

This is the initial page load in development mode; note the two .js files at the very end — we created these in a previous section.
Screenshot of the Network tab in Chrome DevTools

This is what it looks like when language is being switched. The Network tab has been reset for demonstration purposes.
Screenshot of the Network tab in Chrome DevTools

And this is the result of switching to the second lazy tab.
Screenshot of the Network tab in Chrome DevTools


Benefits

  • With this solution you would be able but not obliged to split your translations into several files in any way that you need;
  • It’s reactive, which means that being implemented correctly it provides your users with a seamless experience;
  • It does not require you to install anything that doesn’t ship with Angular out of the box;
  • It’s easily debuggable and fully customizable as it would be implemented directly in your project;
  • It supports complex locale resolutions like relating on browser language, picking up regional settings from user account upon authorization and overriding with a user-defined language — and all this without a single page reload;
  • It also supports code completion in modern IDEs.

Drawbacks

  • As these translation files will not be included in assets, they should actually be transpiled which will slightly increase the build time;
  • It requires you to create a custom utility or use a third-party solution to exchange your translations with a localization platform;
  • It might not work really well with search engines without proper server-side rendering.

GitHub

Feel free to experiment with the fully working example that is available in this repository.
Stay positive and create great apps!

Cover photo by Nareeta Martin on Unsplash

Top comments (0)