Saltar al contenido

Template Literal Types: APIs expresivas y type-safe

Usa los template literal types para codificar nombres de eventos, rutas y permisos en el sistema de tipos: sin comprobaciones en runtime ni desincronización.

5 min de lectura
Código TypeScript que muestra template literal types derivando uniones de nombres de eventos a partir de tipos de dominio

Los template literal types llegaron en TypeScript 4.1, y la mayoría de los equipos los usa para uno o dos trucos antes de seguir adelante. Eso deja la mayor parte de su potencial sin aprovechar. Usados de forma sistemática, permiten codificar contratos a nivel de protocolo — buses de eventos, registros de rutas, sistemas de permisos, convenciones de nomenclatura de tokens CSS — directamente en el sistema de tipos. Cero sobrecarga en tiempo de ejecución, autocompletado completo en el IDE y errores detectados en tiempo de compilación.

La brecha que llenan los template literal types

Las APIs que dependen mucho de cadenas de texto son las más difíciles de mantener type-safe. Los emisores de eventos, los manejadores de rutas, los helpers de CSS-in-JS: todos aceptan cadenas, y sin template literal types, esas cadenas son string (inútil) o una unión escrita a mano (frágil y verbosa). La unión escrita a mano es el verdadero problema: se desincroniza en cuanto se renombra un concepto del dominio.

tstypescript
// ❌ Anything compiles — typos become silent runtime bugs
type EventName = string;
emitter.on("user:created", handler);
emitter.on("usr:created", handler); // typo — no error until runtime
 
// ✅ Derived from domain types — only valid events compile
type UserEvents = `user:${"created" | "updated" | "deleted"}`;
type OrderEvents = `order:${"placed" | "fulfilled" | "cancelled"}`;
type AppEvent = UserEvents | OrderEvents;
 
emitter.on("user:created", handler); // ✅
emitter.on("usr:created", handler);  // ❌ Type error — caught immediately

La unión ya no está escrita a mano. Se deriva de bloques más pequeños, así que cuando agregas un nuevo evento de usuario, cualquier emisor tipado lo recoge automáticamente.

Cómo construir un bus de eventos type-safe

El verdadero beneficio llega cuando combinas template literal types con genéricos para acoplar cada nombre de evento a un tipo de payload específico.

tstypescript
type EventPayloadMap = {
  "user:created": { userId: string; email: string };
  "user:deleted": { userId: string; deletedAt: Date };
  "order:placed": { orderId: string; total: number };
  "order:cancelled": { orderId: string; reason: string };
};
 
type AppEvent = keyof EventPayloadMap;
 
interface TypedEmitter {
  emit<E extends AppEvent>(event: E, payload: EventPayloadMap[E]): void;
  on<E extends AppEvent>(event: E, handler: (payload: EventPayloadMap[E]) => void): void;
}
 
// The compiler enforces the correct payload shape for each event
emitter.emit("user:created", { userId: "u_1", email: "a@example.com" }); // ✅
emitter.emit("user:created", { userId: "u_1", total: 99 });              // ❌ Wrong shape
emitter.emit("order:shipped", { orderId: "o_1" });                       // ❌ Unknown event

El nombre del evento y el payload quedan acoplados a nivel de tipos. Si renombras una clave de evento en EventPayloadMap, se generan errores en todos los lugares donde se usaba el nombre anterior: sin necesidad de hacer grep por todo el código ni de confiar en haber encontrado todo.

Extracción de parámetros de ruta con infer

Los template literal types se combinan con infer dentro de tipos condicionales para extraer estructura a partir de patrones de cadenas. Este es el mecanismo detrás de cualquier router type-safe.

tstypescript
// Recursively extract parameter names from "/users/:id/posts/:postId"
type ExtractParams<Path extends string> =
  Path extends `${infer _Prefix}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${infer _Prefix}:${infer Param}`
      ? Param
      : never;
 
type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};
 
function defineRoute<Path extends string>(
  path: Path,
  handler: (params: RouteParams<Path>, req: Request) => Response,
) {
  return { path, handler };
}
 
defineRoute("/users/:id/posts/:postId", (params) => {
  // params.id ✅, params.postId ✅
  // params.userId ❌ — Property does not exist on type
  return new Response(`Post ${params.postId} by user ${params.id}`);
});

El tipo condicional recursivo recorre la cadena de la ruta y va acumulando los nombres de los parámetros. RouteParams se deriva completamente del literal de la ruta: sin un objeto de esquema aparte, sin decoradores, sin un paso de generación de código.

Sistemas de permisos sin desincronización de cadenas

Las cadenas de permisos son otro lugar donde las uniones escritas a mano se desincronizan constantemente de la realidad. Un mapped type sobre una unión de template literal types resuelve esto.

tstypescript
type Resource = "user" | "order" | "product" | "invoice";
type Action = "create" | "read" | "update" | "delete";
type Permission = `${Resource}:${Action}`;
 
type Role = {
  name: string;
  permissions: Permission[];
};
 
const adminRole: Role = {
  name: "admin",
  permissions: ["user:create", "user:delete", "order:read", "invoice:read"],
};
 
const brokenRole: Role = {
  name: "broken",
  permissions: ["user:destroy"],    // ❌ "destroy" is not a valid action
};

Agregar un nuevo recurso a la unión Resource expande de inmediato el tipo Permission. Cualquier cadena fija que no coincida con una combinación válida se convierte en un error de compilación, incluyendo permisos obsoletos en definiciones de roles que sobrevivieron a un renombramiento.

Tokens de diseño CSS con escalas acotadas

Los sistemas de diseño que generan nombres de propiedades personalizadas de CSS se benefician de convenciones de nomenclatura forzadas en tiempo de compilación, en lugar de reglas de lint que se disparan después de los hechos.

tstypescript
type Scale = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type SpacingToken = `spacing-${Scale}`;
type ColorToken = `color-${"primary" | "neutral" | "danger" | "success"}-${Scale}`;
type DesignToken = SpacingToken | ColorToken;
 
function token(name: DesignToken): string {
  return `var(--${name})`;
}
 
token("spacing-4");          // ✅
token("color-primary-3");    // ✅
token("color-brand-3");      // ❌ "brand" is not in the color set
token("spacing-13");         // ❌ 13 exceeds the scale
~

La unión de literales numéricos 1 | 2 | ... | 12 mantiene el tipo acotado. Si tu escala es abierta, ${number} funciona, pero permite 1.5 y -3, así que valida en tiempo de ejecución cuando la precisión importe.

Cuándo recurrir a los template literal types

No son gratis. Los tipos recursivos ralentizan el compilador de TypeScript, y una maquinaria de tipos demasiado ingeniosa se vuelve dolorosa de depurar seis meses después.

Caso de uso¿Vale la pena?Motivo
Nombres de eventos del bus de eventos✅ SíSe deriva de tipos de dominio y detecta errores tipográficos en el origen
Extracción de parámetros de ruta✅ SíElimina las interfaces de parámetros mantenidas a mano
Cadenas de permisos✅ SíEvita que cadenas obsoletas sobrevivan a renombramientos
Nombres de tokens de diseño✅ SíImpone convenciones sin reglas de lint personalizadas
Análisis recursivo profundo de cadenas⚠️ Tal vezMide con --diagnostics: puede ralentizar tsc de forma significativa
Reemplazar la validación en tiempo de ejecución❌ NoLos tipos se borran; usa Zod/Valibot en los límites de E/S

La última fila importa. Los template literal types operan completamente en tiempo de compilación. Una cadena de permisos proveniente de un JWT, un patrón de ruta de una fila de base de datos, un valor de configuración del entorno: ninguno de estos se beneficia directamente del sistema de tipos. La validación en tiempo de ejecución sigue siendo necesaria en cada límite del sistema; los template literal types y los validadores en tiempo de ejecución son complementarios, no competidores.

i

Ejecuta tsc --diagnostics periódicamente a medida que crece la complejidad de tus tipos. La métrica Instantiation count te indica si los tipos recursivos se están volviendo costosos. Si sube por encima de unos pocos millones, considera simplificar la recursión o dividir la unión.

Puntos clave

  1. Deriva las uniones, no las escribas a mano — compón nombres de eventos, permisos y tokens a partir de tipos de dominio más pequeños para que la unión se mantenga sincronizada cuando algo cambie.
  2. infer habilita el análisis estructural — los tipos condicionales recursivos pueden extraer nombres de parámetros de cadenas de ruta y gramáticas similares hacia objetos tipados precisos.
  3. Combínalos con mapped types para obtener interfaces completas — convertir una unión de template literal types en una interfaz elimina categorías enteras de sincronización manual.
  4. Vigila el rendimiento del compilador — los template literal types profundamente recursivos añaden un costo medible en tsc; usa --diagnostics para detectar regresiones a tiempo.
  5. Los tipos se borran en tiempo de ejecución — valida las cadenas externas con una librería de esquemas en tiempo de ejecución en los límites de E/S; la seguridad a nivel de tipos y la seguridad en tiempo de ejecución cubren superficies de riesgo distintas.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX