Saltar al contenido
White / wsuites

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.