Tipos de literales de template en TypeScript con ejemplos

1. Descripción general

Los tipos de literales de template permiten usar la sintaxis de backticks directamente en el sistema de tipos de TypeScript. Gracias a esto, puedes concatenar, transformar y analizar tipos de cadenas en tiempo de compilación. Este artículo recorre cada característica con ejemplos concretos: concatenar uniones, renombrar claves en mapped types, extraer parámetros de rutas y escribir emisores de eventos seguros de tipos. Después de leerlo, sabrás cuándo aplicar esta técnica. También aprenderás a testearla y reconocerás los casos donde el compilador puede volverse lento o dar errores confusos.

Todos los ejemplos del artículo usan TypeScript 5.x con strict: true.

2. Sintaxis básica

Un tipo de literal de template se ve exactamente como un template string en runtime. La diferencia es que la interpolación ${...} contiene un tipo, no un valor:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

El tipo OrderId acepta cualquier cadena que empiece con ORD- seguida de una cadena que representa un número. Puedes interpolar string, number, bigint, boolean, null, undefined y sus tipos literales correspondientes.

Un ejemplo familiar es un valor CSS con unidad:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // Error: falta "px"

Ahora el compilador atrapa el error de olvidar la unidad. Antes, solo se descubría cuando abrías el navegador.

3. Combinación con uniones: producto cartesiano

Cuando la interpolación es una unión, TypeScript distribuye el template sobre cada miembro. Si hay varias uniones, el resultado es todas las combinaciones posibles:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

Pasa el ratón sobre ButtonClass en el editor y verás:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

Aquí 3 × 2 = 6 miembros se generan automáticamente. Cuando añades "xl" a Size, ButtonClass se actualiza automáticamente. No tienes que corregir nada más.

Nota: El número de combinaciones crece exponencialmente. Si la unión resultante excede 100.000 miembros, TypeScript reportará el error “Expression produces a union type that is too complex to represent”. No concatenes muchas uniones grandes, por ejemplo una unión de 1.000 códigos de producto con una de 200 códigos de país.

4. Tipos de transformación de cadenas integrados

TypeScript proporciona cuatro intrinsic types para cambiar mayúsculas/minúsculas:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

Están integrados en el compilador, no se escriben en TypeScript. Estos cuatro tipos a menudo acompañan a template literals para crear nombres siguiendo convenciones, por ejemplo on${Capitalize<K>} o ${Uppercase<K>}_ENV.

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. Renombrar claves en mapped types

Este es donde los tipos de literales de template brillan más. La cláusula as en mapped types permite renombrar cada clave al crear un nuevo tipo.

5.1. Generar getters desde una interfaz

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;

El resultado es:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

Explicación de cada parte:

  • keyof T & string filtra las claves de tipo number y symbol. La razón es que Capitalize solo acepta string. Si omites & string, obtendrás el error “Type ‘K’ does not satisfy the constraint ‘string’”.
  • as \get${Capitalize}`conviertenameengetName`.
  • () => T[K] conserva el tipo de retorno correcto de la propiedad original.

Una clase que implementa este tipo será atrapada por el compilador si le faltan getters o devuelven el tipo incorrecto:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. Filtrar claves devolviendo never

Si la expresión en as produce never, esa clave se excluye del resultado. Puedes combinar esto con template literals para elegir claves por prefijo:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refund no coincide con el patrón charge${string} por lo que se excluye.

6. Analizar cadenas con infer

Los template literals no solo sirven para concatenar cadenas. También puedes usarlos para dividir cadenas en conditional types, combinados con infer.

6.1. Extraer una parte simple

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

Cuando coincide el patrón, TypeScript asigna la parte restante a Rest.

6.2. Extraer parámetros de una ruta

Este es el ejemplo más común en la práctica. De la cadena "/users/:userId/orders/:orderId", queremos obtener el tipo "userId" | "orderId":

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

Cómo funciona:

  1. La primera rama coincide con "/users/" + ":" + "userId" + "/" + "orders/:orderId". Cada placeholder coincide con la primera ocurrencia del carácter literal que le sigue. Por lo tanto Param es "userId".
  2. Tipo recursivo con "/orders/:orderId". Esta vez no hay / después del parámetro, así que la primera rama falla.
  3. La segunda rama coincide y devuelve "orderId".
  4. Si la cadena no contiene :, el resultado es never.

Ahora úsalo en una función que construye URLs:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...

Resultado:

/users/42/orders/ORD-7

El genérico Path se infiere como un tipo literal del primer argumento. Gracias a esto, el objeto params se verifica exactamente: faltan claves, claves extra o nombres mal escritos generan errores. El casteo a Record<string, string> en el cuerpo de la función es necesario. TypeScript no puede probar que una clave extraída de un regex en runtime pertenece a ExtractParams<Path>.

7. Ejemplo completo: emisor de eventos seguro de tipos

Combinando las técnicas anteriores, tenemos un emisor para el dominio de órdenes:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error

Resultado:

Created ORD-1: 250000
Shipped via GHN

Lo que obtienes:

  • Los nombres de handler onCreated, onShipped, onCancelled se generan automáticamente de OrderEvents.
  • El parámetro p en cada handler se infiere con el tipo correcto del payload, sin necesidad de declararlo.
  • emit("shipped", ...) obliga a que el payload tenga carrier.

Los dos casteos as en emit son donde le decimos al compilador lo que no puede inferir. La cadena construida en runtime no lleva información de tipos. Mantén estos casteos en un alcance pequeño, dentro de la implementación. La API pública sigue siendo completamente segura.

8. Pruebas de tipos con Vitest

La lógica a nivel de tipos también necesita pruebas. Si no, un refactor podría cambiar ExtractParams a string sin que nadie se dé cuenta. Instala Vitest:

npm install -D vitest typescript

Escribe pruebas tanto para el comportamiento de tipos como de runtime:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("extrae correctamente los nombres de parámetros", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("devuelve never cuando no hay parámetros", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("reemplaza parámetros y codifica valores", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("rechaza claves incorrectas", () => {
    // @ts-expect-error - 'id' no es un parámetro de la ruta
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

Ejecuta:

npx tsc --noEmit && npx vitest run

expectTypeOf es una no-op en runtime. Sus errores solo aparecen durante type-checking, así que el paso tsc --noEmit (o vitest --typecheck) es obligatorio. La directiva @ts-expect-error también es una aserción. Si la línea siguiente no tiene un error de tipo, tsc reportará “Unused ‘@ts-expect-error’ directive”.

9. Errores comunes y cuándo no usarlos

Nota: Los tipos de literales de template solo existen en tiempo de compilación. Los datos de APIs, formularios o process.env no se validan de ninguna manera. Con datos externos, todavía necesitas validar en runtime con regex o librerías de esquema.

Algunas trampas específicas:

  • ${number} es más amplio de lo que crees. "1e3px" y "0.5px" son válidos para `${number}px`. Si necesitas “enteros positivos”, template literals no pueden expresarlo. Valida en runtime.
  • Explosión de uniones. Como en la sección 3, concatenar muchas uniones grandes ralentiza mucho el compilador, incluso puede reportar errores de excesiva complejidad.
  • Recursión profunda. Los tipos recursivos como ExtractParams tienen un límite de profundidad. Con cadenas muy largas, encontrarás “Type instantiation is excessively deep and possibly infinite”. Las rutas típicas no alcanzan este límite, pero un parser de cadenas genérico podría.
  • Mensajes de error difíciles de leer. Cuando los tipos son complejos, los errores pueden tener decenas de líneas. Si tus colegas tienen que leer ese error todos los días, el costo puede superar los beneficios.
  • Olvidar & string. keyof T puede contener number | symbol, y Capitalize los rechazará.
  • Abuso. Si el conjunto de valores es pequeño y fijo, escribe directamente la unión literal. Escribir type Size = "sm" | "md" es más legible que cualquier tipo generado automáticamente.

Usa esto cuando hay una fuente de verdad (interfaz, objeto de eventos, cadena de ruta) y quieres que otros tipos se infieran de ella. No lo uses solo para mostrar habilidades a nivel de tipos.

10. Conclusión

Los tipos de literales de template convierten las cadenas en ciudadanos de primera clase en el sistema de tipos de TypeScript. El artículo ha recorrido las técnicas principales:

  • concatenar cadenas y distribuir sobre uniones;
  • cambiar mayúsculas/minúsculas con Uppercase, Capitalize…;
  • renombrar y filtrar claves en mapped types con as;
  • extraer cadenas con infer, incluyendo formas recursivas.

Cuando los aplicas correctamente, como en parámetros de ruta, nombres de eventos o getters, eliminas una clase completa de errores de escritura que antes solo se descubrían en runtime. Recuerda probar tipos con expectTypeOf y @ts-expect-error. Vigila la complejidad de las uniones y la profundidad de recursión. Y siempre valida los datos que provienen de fuera del programa.

typescripttipos de literales de templatesistema de tiposgenéricosvitest