En la banca canadiense, bilingüe no es un feature request. Es un requisito legal. Para un banco que atiende a Quebec, la obligación viene de la Charter of the French Language de Quebec, que el Proyecto de Ley 96 extendió explícitamente en 2022 a las empresas reguladas federalmente que operan en la provincia. A nivel federal, el Proyecto de Ley C-13 promulgó en 2023 el Use of French in Federally Regulated Private Businesses Act, pero esa ley todavía no está en vigor. El Official Languages Act en sí obliga a las instituciones federales, no a los bancos, así que no hay un mandato general del OCOL sobre cada pantalla dirigida al cliente. Lancé features bilingües (EN/FR) con ngx-translate en una app Angular en BMO. Después de haber construido con él, es el enfoque que volvería a elegir por encima del $localize built-in de Angular. El código de este post es un setup genérico para un codebase Angular actual, no el del banco.
Por qué ngx-translate sobre el i18n built-in de Angular
El $localize de Angular requiere un build separado por locale. Eso significa dos artefactos de deployment, un pipeline de CI más complejo, y una recarga completa de página para cambiar idiomas. ngx-translate carga archivos de traducción en runtime vía HTTP y cambia idiomas sin recargar. Para un portal bancario donde los usuarios esperan poder alternar idiomas a mitad de sesión, ese tradeoff es claro. El costo en runtime de cargar un archivo JSON es pequeño a este tamaño.
Configuración
npm install @ngx-translate/core @ngx-translate/http-loader// app.config.ts (ngx-translate v17 provider API, unchanged in v18)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideTranslateService({
fallbackLang: 'en',
loader: provideTranslateHttpLoader({
prefix: './assets/i18n/',
suffix: '.json'
})
})
]
};Estructura de archivos de traducción
No aplanes nada. Anida por dominio de feature. Un archivo plano con 300 claves es una pesadilla de mantenimiento cuando dos personas lo editan simultáneamente.
// assets/i18n/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"loading": "Loading...",
"error": "An error occurred. Please try again."
},
"accounts": {
"title": "My Accounts",
"balance": "Available Balance",
"noTransactions": "No transactions found."
},
"lending": {
"applyNow": "Apply Now",
"creditLimit": "Credit Limit",
"pendingReview": "Your application is under review."
}
}// assets/i18n/fr.json
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"loading": "Chargement...",
"error": "Une erreur s'est produite. Veuillez réessayer."
},
"accounts": {
"title": "Mes comptes",
"balance": "Solde disponible",
"noTransactions": "Aucune transaction trouvée."
},
"lending": {
"applyNow": "Faire une demande",
"creditLimit": "Limite de crédit",
"pendingReview": "Votre demande est en cours d'examen."
}
}LanguageService con estado Signal
Persisto la preferencia en localStorage aquí (para usuarios autenticados, un ajuste en el perfil del lado del servidor los sigue entre dispositivos) y actualizo el atributo html[lang] en cada cambio. Los lectores de pantalla usan ese atributo para seleccionar el perfil de voz correcto. Omitir este paso significa que tu contenido en francés se lee con voz en inglés.
import { DOCUMENT, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
export type Language = 'en' | 'fr';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private translate = inject(TranslateService);
private document = inject(DOCUMENT);
currentLanguage = signal<Language>(
(localStorage.getItem('lang') as Language) ?? 'en'
);
constructor() {
this.translate.addLangs(['en', 'fr']);
this.setLanguage(this.currentLanguage());
}
setLanguage(lang: Language): void {
this.translate.use(lang);
this.currentLanguage.set(lang);
localStorage.setItem('lang', lang);
this.document.documentElement.lang = lang;
}
}Usando TranslatePipe en templates
@Component({
standalone: true,
// TranslatePipe is standalone. TranslateModule still works on v17 but was removed in v18,
// so import the pipe and the sample survives the upgrade.
imports: [TranslatePipe, CurrencyPipe],
template: `
<h1>{{ 'accounts.title' | translate }}</h1>
<p>{{ 'accounts.balance' | translate }}: {{ balance | currency:'CAD' }}</p>
<!-- interpolation -->
<p>{{ 'greeting' | translate:{ name: userName } }}</p>
`
})
export class AccountsComponent {
balance = 4250.00;
userName = 'Marie';
}Traduciendo aria-labels en código de componente
El pipe solo funciona en templates. Para valores de aria-label vinculados desde TypeScript, inyecta TranslateService. No envuelvas instant() en un getter y luego vincules ese getter en el template: un getter es una llamada a método, y se vuelve a ejecutar en cada ciclo de detección de cambios. Resuelve el string una sola vez, o vincula un signal alimentado por stream(), que vuelve a emitir cuando el usuario cambia de idioma.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
@Component({
standalone: true,
template: `<button [attr.aria-label]="closeLabel()">X</button>`
})
export class ModalComponent {
private translate = inject(TranslateService);
// stream() emits the current translation and re-emits on every language change,
// so the label stays correct without a getter running on every change detection pass.
readonly closeLabel = toSignal(this.translate.stream('common.closeModal'), {
initialValue: ''
});
}Pluralización
La pluralización básica funciona con formato de mensajes ICU, pero eso requiere instalar ngx-translate-messageformat-compiler. Para la mayoría de casos en inglés y francés, un patrón más simple usando claves separadas es más fácil de mantener y más fácil para que los traductores trabajen con él.
// en.json, separate keys (no extra package)
{
"transactions": {
"none": "No transactions",
"one": "1 transaction",
"many": "{{count}} transactions"
}
}
// ICU alternative (needs ngx-translate-messageformat-compiler)
{
"transactionCount": "{count, plural, =0 {No transactions} =1 {1 transaction} other {# transactions}}"
}El problema de layout del que nadie te advierte
Los strings en francés son confiablemente 20-30% más largos que sus equivalentes en inglés. Esto significa que cada botón de ancho fijo, cada label truncado, cada celda de tabla con ellipsis se verá mal en francés. La forma clásica de aprenderlo es una ronda de QA tardía en francés que revela roturas de layout en varias páginas de una sola vez. La solución es testear con contenido en francés desde el primer sprint, no el último.
- Usa min-width en lugar de anchos fijos en botones
- Usa text-overflow: ellipsis con tooltip para contenido truncado, no cortes duros
- Ejecuta tu Storybook o tests de componentes con datos de fixture en francés por defecto
- No hardcodees longitudes de línea ni suposiciones de wrapping en CSS
- Para breakpoints móviles, el contenido en francés a menudo requiere un breakpoint extra comparado con inglés
Precargando traducciones para un render inicial más rápido
Por defecto, ngx-translate obtiene los archivos de traducción después de que la app arranca. Los usuarios en conexiones lentas ven brevemente las claves crudas. Bloquea el primer render en esa petición con provideAppInitializer. APP_INITIALIZER está deprecado desde Angular 19 y la forma de función igual tiene menos ceremonia: sin factory, sin array de deps, inject() directamente adentro.
// app.config.ts
import { inject, provideAppInitializer } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
// Goes in the same providers array as provideTranslateService from the setup block.
// It runs in an injection context, so inject() works directly.
provideAppInitializer(() => {
const translate = inject(TranslateService);
const lang = (localStorage.getItem('lang') as 'en' | 'fr') ?? 'en';
translate.addLangs(['en', 'fr']);
// The fallback is already configured as fallbackLang in provideTranslateService. To change it
// at runtime, call setFallbackLang, which replaced setDefaultLang in v17.
return firstValueFrom(translate.use(lang));
});Bilingüe no es un sprint al final. Diseña tus claves de traducción, tu sistema de layout, y tus contratos de datos con ambos idiomas desde la primera semana.
Referencias
- ngx-translate. https://github.com/ngx-translate/core
- Angular provideAppInitializer. https://angular.dev/api/core/provideAppInitializer
- Angular locale ID. https://angular.dev/guide/i18n-common-locale-id
- Angular DatePipe. https://angular.dev/api/common/DatePipe