Práctico 3 min de lectura
Traducciones tipadas: que falte una cadena sea un error de build
Dos idiomas desde un mismo build estático, donde TypeScript se niega a compilar si falta una traducción en vez de caer en silencio al inglés.
Publicado
La forma habitual de añadir un segundo idioma es un diccionario de claves y una
función t():
t("home.hero.title");
Funciona, y falla en silencio. Renombra una clave y aparece undefined en una
página. Olvida una traducción y quien visita el sitio lee inglés en una URL en
español sin que nada avise. Nada se rompe lo bastante alto como para que lo
notes antes que un lector.
Que compruebe el compilador
La alternativa no cuesta más y elimina la clase entera de problema: haz que la copia en inglés sea un objeto normal, deriva un tipo de él y obliga a los demás idiomas a satisfacer ese tipo.
// copy/en.ts: la fuente de verdad
export const en = {
nav: { work: "Work", writing: "Writing", about: "About" },
cta: {
lead: "Free consultation",
action: "Discuss a project",
},
};
export type Copy = typeof en;
// copy/es.ts: comprobado contra él
import type { Copy } from "./en";
export const es: Copy = {
nav: { work: "Trabajo", writing: "Notas", about: "Perfil" },
cta: {
lead: "Consultas gratis",
action: "Hablemos del proyecto",
},
};
Si falta una clave, tsc falla indicando la ruta exacta. Si renombras una en
inglés, fallan todos los idiomas que no se hayan puesto al día. No hay
indirección de claves de texto que equivocar, porque no hay claves: solo acceso a
propiedades que el editor autocompleta.
<h1>{copy.home.title}</h1>
Un detalle importa: no pongas as const en el objeto inglés. Con as const
cada cadena se estrecha a su propio tipo literal y el archivo español estaría
obligado a contener el mismo texto en inglés. Sin él, los tipos se ensanchan a
string y string[], así que se comprueba la estructura y el contenido queda
libre, que es justo el contrato que quieres.
Rutas: el idioma por defecto sin prefijo
El inglés se queda en / y el español en /es/. Las URLs existentes siguen
funcionando, y eso importa más que la simetría:
i18n: {
defaultLocale: "en",
locales: ["en", "es"],
routing: { prefixDefaultLocale: false },
}
Tres funciones puras pequeñas resuelven el resto, y vale la pena escribirlas con cuidado porque cada enlace del sitio pasa por ellas:
export function localizePath(path: string, locale: Locale): string {
const clean = stripLocale(path);
if (locale === DEFAULT_LOCALE) return clean;
return clean === "/" ? `/${locale}` : `/${locale}${clean}`;
}
stripLocale se ejecuta primero para que localizePath sea idempotente: pasarle
una ruta ya localizada devuelve el resultado correcto y no /es/es/about.
Contenido, y ser honesto con los huecos
El contenido largo vive en un directorio por idioma:
src/content/blog/
├── en/typed-translations-static-build.mdx
└── es/typed-translations-static-build.mdx
El id de una entrada pasa a ser en/typed-translations-static-build, así que el
loader no cambia y el idioma es simplemente el primer segmento de la ruta.
Las traducciones de artículos largos van con retraso: es lo normal. Lo que sí hay que diseñar es qué pasa cuando falta una. Caer al inglés es correcto; hacerlo en silencio, no:
const translated = byLocale.get(slug);
const entry = translated ?? fallback.get(slug);
posts.push({ entry, slug, translated: Boolean(translated) });
La marca translated viaja con la entrada y la página muestra un aviso corto
cuando es falsa. Al lector se le dice qué idioma está leyendo en realidad, en vez
de dejar que lo deduzca.
Lo que esto no resuelve
El tipado garantiza que la cadena existe. No puede decirte si la traducción es buena, si una fecha se lee con naturalidad o si una frase sigue cabiendo en su botón en alemán. Eso necesita a una persona.
Lo que sí compra es que el modo de fallo cambia de “lo encuentra quien visita el sitio” a “el build se niega”. Ese es el intercambio que quiero en cualquier proyecto: empujar los errores todo lo temprano que se pueda y aceptar que los que quedan son los interesantes.