Rewritten in Feb 2022. I’ve adjusted content to new Angular Ivy possibilities.
Built-in Angular translation is powerful and performant with runtime overhead close to zero. Since Angular 10, it got additional features for translating messages in TypeScript like enums.
There are two approaches to mark messages as translatable.
$localize
global markeri18n
attributeI’ll present both approaches to solve problem of translating enums.
Its common scenario to use enums for grouping state messages. Let’s make an example in simple Todo component.
interface TodoItem {
name: string;
state: TodoState;
}
// Messages in enum
enum TodoState {
TODO = 'Started',
IN_PROGRESS = 'In progress',
DONE = 'Finished',
}
// 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[];
}
So what is the problem?
{{ items.state }}
will produce generated enums values (0, 1, 2… or ‘TODO’, ‘IN_PROGRESS’ or ‘Started’, ‘In Progress’…)$localize
In Angular 10, team has introduced global marker $localize
. We can translate enums and strings in-place.
This is enough to let angular compiler know to replace messages to different language.
enum TodoState {
TODO = $localize`Not started`,
IN_PROGRESS = $localize`In progress`,
DONE = $localize`Finished`,
}
Sometimes we don’t want to put messages into enum, or enum have already predefined values. To provide messages of enum, we can create function with switch-case. Same as before, apply $localize
marker.
enum TodoState {
TODO, IN_PROGRESS, DONE,
}
function getStateMessage(state: TodoState): string {
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`;
}
}
Every string visible in UI can to be put into HTML template. For messages in HTML, we can apply i18n
attribute to mark them as translatable.
For complex messages calculation (enums, or some text logic) we can create new component responsible only for translation. We’re using this practice widely in our applications, making a clear separation of concerns and focusing only on logic around messages.
enum TodoState {
TODO, IN_PROGRESS, DONE,
}
@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;
}
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[];
}
const enum
cannot be used within template (at least, not out of the box)TodoState.BLOCKED
)ICU messages are special syntax to handle select
or plural
kind of messages. Here it works pretty same as switch-case, with less verbose syntax.
@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;
}
select
is not well-supported in translation tools ðTODO {<span>not</span> started}
)Whenever we have switch-cases in template, we can make sure we have
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');
});
});
});