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';
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>;
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;
}
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',
};
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],
};
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"
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;
}
Implement this interface:
export const lang: I18nTab1 = {
country: 'Country',
};
Include your translations into compilation:
"../../libs/tab1/src/lib/i18n/*.lang.ts"
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],
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 {}
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>,
) {}
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>
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.
This is what it looks like when language is being switched. The Network tab has been reset for demonstration purposes.
And this is the result of switching to the second lazy tab.
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)