DEV Community

Piotr Lewandowski
Piotr Lewandowski

Posted on • Updated on • Originally published at piotrl.net

Angular - Translate Enums (i18n)

Built-in Angular translation engine supports (so-far) only translation within templates, so you might think it's not possible to translate parts of TypeScript like enums.

In fact, it's quite easy if you follow one practice.

Model from server

To make things easy to reason about, let's have a Todo App :)

interface TodoItem {
  name: string;
  state: TodoState;
}

enum TodoState {
  TODO = 'TODO',
  IN_PROGRESS = 'IN_PROGRESS',
  DONE = 'DONE',
}
Enter fullscreen mode Exit fullscreen mode

Usage

@Component({
  selector: 'todo-list',
  template: `
    <ul>
      <li *ngFor="let item of items">
      {{ item.name }} ({{ item.state }})
      </li>
    </ul>
  `,
})
export class TodoList {

  @Input()
  items: TodoItem[];
}
Enter fullscreen mode Exit fullscreen mode

So what is the problem?

  1. {{ items.state }} will produce generated enums values (0, 1, 2... or 'TODO', 'IN_PROGRESS'...)
  2. We need to convert enum value into string, however this has to be within template, not TypeScript

Bad example

Often we tend to create method with switch-case, which is unfortunate because Angular i18n is not aware of those strings, and so - it won't touch them during translation.

// Don't do it
getStateMessage(state: TodoState) {
  switch(state) {
    case TodoState.TODO:
      return 'not started';
    case TodoState.IN_PROGRES:
      return 'started';
    case TodoState.DONE:
      return 'finished';
    default:
      return 'unknown';
  }
}
Enter fullscreen mode Exit fullscreen mode

How to make it translatable?

There is only one rule to follow:

Every string visible in UI has to be put in template

Usually in our team, for complex string calculation (enums, or some text logic) we create new component responsible only for translation.

We're using it widely in our applications, making a clear distinction between screen-logic and text-logic.

Solution #1

@Component({
  selector: 'todo-state-i18n',
  template: `
  <ng-container [ngSwitch]="key">
    <ng-container i18n *ngSwitchCase="todoState.TODO">not started</ng-container>
    <ng-container i18n *ngSwitchCase="todoState.IN_PROGRESS">started</ng-container>
    <ng-container i18n *ngSwitchCase="todoState.DONE">finished</ng-container>
    <ng-container i18n *ngSwitchDefault>not defined</ng-container>
  </ng-container>
  `,
})
export class TodoStateI18n {

  // enum has to be accessed through class field
  todoState = TodoState;

  @Input()
  key: TodoState;
}
Enter fullscreen mode Exit fullscreen mode

And final usage:

@Component({
  selector: 'todo-list',
  template: `
    <ul>
      <li *ngFor="let item of items">
      {{ item.name }} (<todo-state-i18n key="item.state"></todo-state-i18n>)
      </li>
    </ul>
  `,
})
export class TodoList {

  @Input()
  items: TodoItem[];
}
Enter fullscreen mode Exit fullscreen mode
  • This works only with regular enums, const enum cannot be used within template (at least, not out of the box)
  • We happily use this practice not only for enums, but also for string manipulations.
  • You still need to remember to update template when new enum values are added (e.g. TodoState.BLOCKED)

Solution #2 - ICU messages

@Component({
  selector: 'todo-state-i18n',
  template: `
  <ng-container i18n>
    {key, select,
      TODO {not started}
      IN_PROGRESS {started}
      DONE {finished}
    }
  </ng-container>
  `,
})
export class TodoStateI18n {

  @Input()
  key: TodoState;
}
Enter fullscreen mode Exit fullscreen mode
  • Works with const enums
  • Useful especially for string enums
  • Simpler approach, but also supports HTML elements e.g. TODO {<span>not</span> started})
  • To be secure, you need to write unit tests that checks enum values

Top comments (7)

Collapse
 
plondrein profile image
Dominik

This is one nice approach ! One minor detail, though - can this default value in ngSwitch might lead to hide new untranslated enum values?

For third final note, I imagine test enumerating through all enum values that reminds us about new values to translate.

Collapse
 
constjs profile image
Piotr Lewandowski • Edited

for enums that tends to change or grow, we write unit tests that iterate over enum and make sure all values are included.

Example could be as simple as:

describe('TodoStateI18n', () => {
  let component: TodoStateI18n;
  let fixture: ComponentFixture<TodoStateI18n>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [
        TodoStateI18n,
      ],
    })
      .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TodoStateI18n);
    component = fixture.componentInstance;
  });

  // regular specs
  it('should display correct text for TODO', () => {
    component.value = TodoState.TODO;

    fixture.detectChanges();

    expect(fixture.nativeElement.textContent)
      .toBe('not started');
  });

  // checking if everything is translated
  // Cannot be `const enum`
  Object.values(TodoState)
    .forEach((value) => {
      it(`should translate ${value}`, () => {
        component.value = value;

        fixture.detectChanges();

        expect(fixture.nativeElement.textContent)
          .not
          .toBe('unknown');
      });
    });
});
Collapse
 
hopmandahinda profile image
HopmanDahinda

I have gotten the ICU approach working, but I've come across another problem, I would like to have a hover title on my enum. Do you have any idea how to accomplish that? I've tried everything I could think of.

Collapse
 
constjs profile image
Piotr Lewandowski • Edited

Right, it might cause duplication in ICU approach. I'd go with first solution then.

@Component({
  selector: 'todo-state-i18n',
  template: `
  <ng-container [ngSwitch]="key">
    <span i18n *ngSwitchCase="todoState.TODO" title="not started" i18n-title>not started</ng-container>
    <span i18n *ngSwitchCase="todoState.IN_PROGRESS" title="started" i18n-title>started</ng-container>
    <span i18n *ngSwitchCase="todoState.DONE" title="finished" i18n-title>finished</ng-container>
    <span i18n *ngSwitchDefault title="not defined" i18n-title>not defined</ng-container>
  </ng-container>
  `,
})
export class TodoStateI18n {

  // enum has to be accessed through class field
  todoState = TodoState;

  @Input()
  key: TodoState;
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Elements cannot be <ng-container> anymore with title attribute. I went with <span>
  • You have to add i18n-title to translate attribute

Ivy update

If you use Ivy (and the best Angular 11.2+), there is new package @angular/localize, that can translate strings in TypeScript.

So you could have re-usable function for providing messages:

getStateMessage(state: TodoState) {
  switch(state) {
    case TodoState.TODO:
      return $localize`not started`;
    case TodoState.IN_PROGRES:
      return $localize`started`;
    case TodoState.DONE:
      return $localize`finished`;
    default:
      return $localize`unknown`;
  }
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
hopmandahinda profile image
HopmanDahinda

Wow, thanks for the really quick reply!

I thought that the first approach is not suitable for const enum, and since our company prefers using const enums I would rather not change that. Do you know any other way? If not, I will use the first approach anyway.

We don't use Ivy unfortunately.

Thread Thread
 
constjs profile image
Piotr Lewandowski • Edited

Yep, it has to be regular enum.

I also maintain practice to keep const enum, but they do not fit in every use case. Overhead is slightly bigger but not crazy

Thread Thread
 
hopmandahinda profile image
HopmanDahinda

Ok I will change it to a regular enum. Thank you for your help!