Back to website.

Using i18n programmatically in Angular

Iulian-Constantin Marcu
Iulian-Constantin Marcu
Cover Image for 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 uses String.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:

  1. Scan through all the template files in the application and look for elements which have the i18n HTML attribute
  2. Look for the ID (value of i18n attribute) in the translation file for the language we are currently compiling (eg. es_ES)
  3. Replace the content of the element with the text it found in the translation file
  4. 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:

  1. Create an HTML element, that has the i18n attribute, plus our appTranslation directive.
  2. At compile time, Angular will come and replace the element text with the one appropiate for the language we target.
  3. 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.
  4. 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.

Share this article

Other Articles

Cover Image for The Effects of AI Development Assistants

The Effects of AI Development Assistants

AI-powered tools like GitHub Copilot, Cursor, and Windsurf significantly boost developer productivity, potentially turning average developers into 10x developers. While AI can handle basic tasks, developers with domain knowledge are crucial for breaking down complex problems and guiding AI to optimal solutions.

Cover Image for Effortless Offloading: Next.js Meets WebWorkers

Effortless Offloading: Next.js Meets WebWorkers

In this post, we build an image preview page, discover its limitations, and then offload the work to WebWorkers to achieve high performance by using the next-webworker-pool NPM package.

Cover Image for Level up your React codebase quality

Level up your React codebase quality

In this blog post, we will explore strategies and best practices for improving code quality in React applications. Whether you're a seasoned React developer or just getting started, you'll find valuable insights to elevate your codebase and streamline your development process.

Cover Image for Effective communication for software engineers

Effective communication for software engineers

As a software engineer, discussing technical subjects with colleagues from other departments needs to happen as flawlessly as possible. In this post, I describe my approach to maximize the value of any meeting.