Using i18n programmatically in Angular
The Angular framework provides powerful internationalization (i18n) capabilities out-of-the-box. One of the issues, though, is the lack of a method to use i18n-able texts programmatically, via code.
Let's look at a very common usecase for which one would need a way to use translations via code: displaying API errors in a user-friendly way.
Say that we are making a request to an API, and for specific conditions it will return different error codes, depending on the request, like this:
{
"error": {
"reason": "PAYMENT_METHOD_NOT_SUPPORTED",
"method": "MyCardBrand"
}
},
{
"error": {
"reason": "PROJECT_QUOTA_EXCEEDED",
"project": "My Cool Project",
"limit": 10
}
},
{
"error": {
"reason": "UNKNOWN_ERROR"
}
}
Obviously, it would be a terrible user experience if we would display these error codes to the user when one of these errors ocurred, expecially if they are not English speakers.
Static texts
If we are trying to statically display these messages on the page, the go-to way to achieve i18n for these messages would be to simply declare the DOM elements on the page and use the i18n capabilities of Angular, plus ngIf
or ngSwitch
to choose the correct message:
<div class="error-message" *ngIf="error">
<ng-container [ngSwitch]="error.reason">
<span *ngSwitchCase="PAYMENT_METHOD_NOT_SUPPORTED" i18n="@@error.payment.not.supported">
Payment method "{{ error.method }}" is not supported. Please try again with a different one.
</span>
<span *ngSwitchCase="PROJECT_QUOTA_EXCEEDED" i18n="@@error.quota.exceeded">
You reached the limit of {{ error.limit }} for project "{{ error.project }}". Please contact support to increase the limit.
</span>
<span *ngSwitchDefault i18n="@@error.unexpected.error">
An unexpected error has occurred. Please try again or contact support.
</span>
</ng-container>
</div>
Dynamic texts
That does the job for texts that are displayed in a static fashion, like in a form, but what if we try to display a text via code, using an API similar to the following:
// toastService is injected by Angular inside our component
this.toastService.showError('An error has occurred, please try again.');
In this situation, there is nothing out-of-the-box provided by Angular, so we will have to implement the solution by ourselves in order to solve the problem.
Implementing the Translation Service
The API we would want to use is:
this.translationService.get(text, interpolatedArguments);
For the error in a toast example, this would be:
// params will be { project, limit } or anything that we might want to display
const { reason, ...params } = error;
this.toastService.showError(
this.translationService.get(reason, params)
);
So how does the TranslationService work?
In essence, the TranslationService
is a place in which we want to store all the texts we possibly want to use inside our code, plus a method of retreiving the value of a text after interpolating the provided arguments (params
in the example above).
For the arguments interpolation, we will have to define a convention over how we will mark their positions in the text. I decided to use a template like $$param_name$$
, as it is very unlikely to have something formatted in such a way in our user interface.
Let's first define an Angular service that we will later be able to inject in our components and directives. We will store the translated texts inside a plain Javascript object:
import {Injectable} from '@angular/core';
@Injectable()
class TranslationService {
// this is where we store the translations as (key: text)
private readonly translations: Record<string, string> = {};
}
We will need a way to store new translations in the service, whenever we load more:
class TranslationService {
...
public put(key: string, value: string) {
if (this.translations[key] != null) {
// we show a warning in case we are overwriting a translation
console.warn(`Overwriting translation for key "${key}"`);
}
// then simply set the translation to the value provided
this.translations[key] = value;
}
...
}
And finally, we need to create a method that we will use to retrieve the text of the translation, with its arguments interpolated:
class TranslationService {
...
public get(key: string, params: string) {
const text = this.translations[key];
if (text == null) {
// we show an error if we requested a translation that doesn't exist yet
console.error(`Translation for key "${key}" not found.`);
// and return the key, so it is obvious in the UI where that translation is used
return key;
}
return this.interpolate(this.translations[key], params);
}
private interpolate(text: string, params: string) {
let result = text;
// replace each of the parameters with its value in the source text
for (const [name, value] of Object.values(params)) {
result = result.replaceAll(`$$${name}$$`, value);
}
return result;
}
...
}
Make note that the
interpolate
method usesString.prototype.replaceAll
, which was recently introduced in Javascript. Check out if the browser you are targeting supports this functionality.There are other ways to replace all ocurrences of a substring in a string, for example a simple
while
loop looking for matches of a RegEx could do the trick.
Populating the TranslationService with translations
So now that we have the service implemented, we need to populate it with actual usable translation texts. Being such a simple service, the translations can be populated in many ways, like fetching via a translation API, reading from a JSON file or simply pushing texts via Javascript.
Since Angular is already providing a method for i18n that we use, we don't want to have to maintain another way of handling translations.
How does the Angular i18n mechanism work?
Let's have alook at how the default Angular i18n works. Say we have the following element:
<span i18n="@@hello.world">Hello World!</span>
During compile time, Angular will do the following:
- Scan through all the template files in the application and look for elements which have the
i18n
HTML attribute - Look for the ID (value of
i18n
attribute) in the translation file for the language we are currently compiling (eg.es_ES
) - Replace the content of the element with the text it found in the translation file
- Remove the
i18n
attribute from the element and move to next element
So, in the end, we are left with the following:
<span>Hola Mundo!</span>
Welcome the TranslationDirective
So, for our usecase, we can create a directive that will run on top of Angular's i18n mechanism, but at runtime, so that we can populate the translation service. Let's call it appTranslation
and make it behave like the following:
- Create an HTML element, that has the
i18n
attribute, plus ourappTranslation
directive. - At compile time, Angular will come and replace the element text with the one appropiate for the language we target.
- At runtime, when the element is loaded in the page, our directive will run, grab the text from the element and put it in the
TranslationService
. - Finally, since this element is used only for code translations, we can remove the element.
So the following:
<span [appTranslation]="@@hello.world" i18n="@@hello.world">Hello World!</span>
Will become:
<span [appTranslation]="@@hello.world">Hola Mundo!</span>
Note the fact that we will need to provide a translation ID to our directive too, in this case I chose the same one used for the Angular i18n for consistency, but that is not required.
That is because, as stated above, Angular will remove the
i18n
attribute from the elements after translation.
How do we implement this?
First, we define our directive, in which we inject the TranslationService
and get a reference of the current element.
import { Directive } from '@angular/core';
import { TranslationService } from './translation.service';
@Directive({
selector: '[appTranslation]'
})
class TranslationDirective {
constructor(
private readonly elementRef: ElementRef,
private readonly translationService: TranslationService,
) {}
}
Then, we want to read the key we provided to appTranslation
, to use for the TranslationService
:
class TranslationDirective {
@Input() appTranslation: string;
}
Then, on initialization of the directive, so when any element decorated with this directive is loaded, we want to grab its text, send it to the TranslationService
and remove the element in order to cleanup the page.
...
import { OnInit } from '@angular/core';
class TranslationDirective implements OnInit {
...
ngOnInit() {
// grab the actual DOM element
const element = this.elementRef.nativeElement;
// add the translation in the translation service
this.translationService.put(this.appTranslation, element.textContent);
// remove the element
element.remove();
}
...
}
And that is the directive done!
Using the new translation system
Whenever you have a component that will need to display a text using code, you can use the TranslationDirective
and TranslationService
we created above:
Our template will contain the following:
test.component.html
...
<span appTranslation="PAYMENT_METHOD_NOT_SUPPORTED" i18n="@@error.payment.method.not.supported">Payment method "$$method$$" is not supported. Try again with a different one.</span>
<span appTranslation="PROJECT_QUOTA_EXCEEDED" i18n="@@error.project.quota.eceeded">Project limit of $$limit$$ exceeded for project "$$project$$". Contact support to increase it.</span>
<span appTranslation="UNKNOWN_ERROR" i18n="@@error.unknown.error">An error occurred. Try again or contact support if the issue persists.</span>
Then, in our component code, we can simply do:
test.component.ts
...
class TestComponent {
constructor(
...,
private readonly translationService: TranslationService
) {}
...
showError(error: {reason: string, params: Record<string, any>}) {
this.toastService.showError(
this.translationService.get(error.reason, error.params);
);
}
}
When we compile the application, Angular will make sure that the text in those elements is translated, then our custom directive will make sure the text is inserted in the translation service.
Conclusion
Whilst Angular is one of the most comprehensive web frameworks, oferring most of the required functionalities for a web application out-of-the-box, like templating, routing, dependncy injection and internationalization to name a few, there are always situations in which we, the application developers, need to implement custom solutions.
Fortunately, as you could see in this situation, we managed to use the existing mechanisms in our favor and achieve the desired result with no hassle.