Al diseñar librerías, enrutadores, máquinas de estado, sistemas de diseño y clientes de API, es primordial retener literales de cadena exactos ("/api/v1/users", "POST", "light"), primitivas numéricas y tuplas, en lugar de degradarlos a tipos genéricos como string o number.
Antes de TypeScript 5.0, el compilador aplicaba una ampliación de tipos agresiva (Type Widening) al inferir parámetros genéricos a partir de los argumentos suministrados. Para forzar la preservación de literales o tuplas inmutables, los desarrolladores de bibliotecas debían delegar la disciplina de tipado en cada usuario, exigiendo una aserción manual as const en cada llamada:
// Legacy TypeScript (anterior a 5.0): el consumidor DEBÍA recordar 'as const'
const router = createRouter({
routes: ['/dashboard', '/settings', '/analytics'] as const,
});Si el programador olvidaba incluir as const, el compilador infería string[]. Como consecuencia, el autocompletado de rutas dejaba de funcionar en la base de código y los errores tipográficos en los endpoints se convertían en fallos silenciosos en tiempo de ejecución.
A partir de TypeScript 5.0, el modificador const Type Parameters (<const T>) traslada la responsabilidad de retener literales desde el código consumidor hacia la firma expuesta por el arquitecto de la librería, ofreciendo una experiencia de desarrollo (DX) impecable y sin sobrecarga sintáctica.
El modificador <const T> traslada la preservación de literales desde el punto de invocación hacia la firma de la biblioteca: el compilador interpreta los literales en línea como si llevaran implícito un as const.
Para comprender cómo resuelve el compilador los parámetros de tipo, contrastemos dos aproximaciones:
function fn<T>(arg: T)): El compilador asume la posibilidad de mutaciones futuras en el objeto o array recibido. Por ello, { role: 'admin' } adquiere el tipo { role: string } y el array ['GET', 'POST'] se clasifica como una lista mutable string[].function fn<const T>(arg: T)): El compilador asume inmutabilidad y máxima fidelidad estructural:
"GET" | "POST" en vez de string).404 | 500 en vez de number).readonly ["/users", "/billing"]).readonly en todos sus niveles.+-------------------------------------------------------------------------+
| Evolución de la resolución de literales en Generics |
+-------------------------------------------------------------------------+
| Previo a TypeScript 5.0 (Type Widening): |
| function createRouter<T extends string[]>(routes: T): T; |
| createRouter(['/home', '/about']); |
| => T se resuelve como string[] (¡literales perdidos!) |
| createRouter(['/home', '/about'] as const); |
| => T se resuelve como readonly ["/home", "/about"] (requiere as const)|
+-------------------------------------------------------------------------+
│
▼ Lanzamiento TypeScript 5.0+ (<const T>)
+-------------------------------------------------------------------------+
| TypeScript moderno (<const T>): |
| function createRouter<const T extends readonly string[]>(routes: T): T; |
| createRouter(['/home', '/about']); |
| => T se infiere automáticamente como readonly ["/home", "/about"] |
| • Cero código repetitivo para el usuario |
| • IntelliSense exhaustivo en rutas y métodos HTTP |
+-------------------------------------------------------------------------+El modificador const solo está permitido exclusivamente delante de parámetros de tipo en funciones, métodos y constructores. No es válido declararlo sobre clases o interfaces completas:
function makeConfig<const T>(c: T): T — ✅ Válido.class Router<const T> {} — ❌ Error sintáctico del compilador (TS1458: Type parameter declaration cannot have a 'const' modifier). En clases, const se asocia a constructores o funciones factoría.El Type Checker localiza el modificador const precediendo al parámetro genérico (<const T extends ...>).
El compilador evalúa si el argumento es un literal directo de objeto/array o una referencia a una variable previa.
Se inhibe la generalización de tipos: las primitivas se fijan como literales estrictos de cadena y numéricos.
Los arrays se transforman en tuplas inmutables readonly [...] y los objetos blindan sus propiedades.
El tipo inferido se enlaza al valor de salida, activando un autocompletado riguroso en el IDE.
Al declarar `function defineTokens<const T>(tokens: T): T`, ¿por qué pasar `{ primary: '#3b82f6', variants: [100, 200] }` preserva variants como `readonly [100, 200]` en lugar de `number[]`?
En bibliotecas de rutas (Next.js, Express, TanStack Router), las URLs y verbos HTTP se definen mediante arrays de configuración. Con <const T>, el consumidor entrega un array ordinario de JavaScript y los métodos del cliente reciben autocompletado estricto restringido únicamente a las rutas válidas.
En motores de máquinas de estado (XState, FSMs personalizadas), los estados y transiciones componen un grafo. Los identificadores (idle, loading, success, error) se infieren como uniones discriminadas exactas, impidiendo transitar hacia estados inexistentes.
En herramientas de tokens de diseño (Tailwind, Vanilla Extract, Panda CSS), las paletas cromáticas y escalas de espaciado se suministran en objetos. Gracias a <const T>, los colores se resuelven como cadenas hexadecimales o claves semánticas concretas, evitando entradas arbitrarias.
Al especificar proyecciones mediante query.select(['id', 'email', 'created_at']), el argumento se tipa como readonly ['id', 'email', 'created_at'], permitiendo que el objeto retornado compute Pick<User, 'id' | 'email' | 'created_at'> sin requerir genéricos manuales.
Analizaremos la arquitectura de un enrutador para API Gateway donde rutas, métodos HTTP y cuotas se comprueban estrictamente en compilación.
El desarrollador declara las rutas como un array de objetos JS comunes { path, method } sin as const.
El genérico <const TConfig extends RouterConfig> preserva las rutas como readonly RouteDefinition[].
Tipos auxiliares calculan la unión TRoutes[number]['path'] y asocian los métodos HTTP válidos.
El método router.hasRoute() solo admite combinaciones registradas (ruta, método).
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface RouteDefinition<
TPath extends string = string,
TMethod extends HttpMethod = HttpMethod
> {
readonly path: TPath;
readonly method: TMethod;
readonly rateLimit?: number;
}
export interface RouterConfig<TRoutes extends readonly RouteDefinition[]> {
readonly prefix?: string;
readonly routes: TRoutes;
}
/**
* Clase de enrutador tipado.
* Nota: el modificador const se declara en la función factoría,
* mientras que la clase delimita su genérico con readonly.
*/
export class TypedRouter<TRoutes extends readonly RouteDefinition[]> {
public readonly routes: TRoutes;
public readonly prefix: string;
constructor(config: RouterConfig<TRoutes>) {
this.routes = config.routes;
this.prefix = config.prefix ?? '';
}
/**
* Comprueba si un endpoint existe. ¡El método está fuertemente acoplado a la ruta!
*/
public hasRoute<
TPath extends TRoutes[number]['path'],
TMethod extends Extract<TRoutes[number], { readonly path: TPath }>['method']
>(path: TPath, method: TMethod): boolean {
return this.routes.some(
(r) => r.path === path && r.method === method
);
}
/**
* Devuelve todas las rutas registradas
*/
public getRegisteredPaths(): readonly TRoutes[number]['path'][] {
return this.routes.map((r) => r.path);
}
}
/**
* Función factoría con el modificador const precediendo a TConfig.
* ¡Aquí es donde actúa la inferencia avanzada de TypeScript 5.0+!
*/
export function createRouter<
const TConfig extends RouterConfig<readonly RouteDefinition[]>
>(config: TConfig): TypedRouter<TConfig['routes']> {
return new TypedRouter(config);
}¿Qué ocurre si un desarrollador declara: `class ApiService<const TConfig> { constructor(cfg: TConfig) {} }`?
Ejemplo de producción del enrutador API Gateway demostrando validación en compilación y autocompletado exhaustivo sin recurrir a as const.
import { createRouter } from '@/lib/typed-router';
// ¡Objeto literal limpio sin aserciones `as const`!
export const apiGateway = createRouter({
prefix: '/api/v1',
routes: [
{ path: '/auth/login', method: 'POST', rateLimit: 5 },
{ path: '/auth/logout', method: 'POST' },
{ path: '/users', method: 'GET', rateLimit: 60 },
{ path: '/users/:id', method: 'GET' },
{ path: '/users/:id', method: 'DELETE' },
{ path: '/billing/invoices', method: 'GET' },
],
});
// ==========================================
// 1. Escenarios válidos (Compilación exitosa)
// ==========================================
const check1 = apiGateway.hasRoute('/auth/login', 'POST'); // ✅ OK
const check2 = apiGateway.hasRoute('/users', 'GET'); // ✅ OK
const check3 = apiGateway.hasRoute('/users/:id', 'DELETE'); // ✅ OK
// ==========================================
// 2. Errores estáticos de compilación:
// ==========================================
// @ts-expect-error Error: '/billing/invoices' solo admite 'GET', no 'DELETE'
apiGateway.hasRoute('/billing/invoices', 'DELETE');
// Argument of type '"DELETE"' is not assignable to parameter of type '"GET"'.
// @ts-expect-error Error: '/admin/settings' no está registrada
apiGateway.hasRoute('/admin/settings', 'GET');
// Argument of type '"/admin/settings"' is not assignable to parameter of type
// '"/auth/login" | "/auth/logout" | "/users" | "/users/:id" | "/billing/invoices"'.El modificador <const T> solo surte efecto cuando el argumento se entrega como un literal directo en línea en la llamada. Si el array u objeto fue declarado previamente sin as const, el compilador ya amplió su tipo a string[] en la declaración y const T no puede reconstruir los literales originales.
// ❌ ANTIPATRÓN: Declaración previa que amplía el tipo a string[]
const endpoints = ['/users', '/billing']; // Tipo inferido: string[]
const router = createRouter({ routes: endpoints.map(p => ({ path: p, method: 'GET' })) });
// T se infiere como RouteDefinition<string, string>[], ¡autocompletado inutilizado!
// ✅ CORRECTO: Literal suministrado directamente en la factoría
const router = createRouter({
routes: [
{ path: '/users', method: 'GET' },
{ path: '/billing', method: 'GET' },
],
}); // Inferencia estricta: readonly [{ path: '/users', ... }]Dado que <const T> infiere arrays como readonly [...], enviar este resultado a una función externa que exige T[] mutable provocará un error de asignación:
The type 'readonly [...]' is 'readonly' and cannot be assigned to the mutable type '...[]'.
// ❌ ERROR: La función requiere un array mutable
function mutateRoutes(routes: string[]) {
routes.push('/internal');
}
const config = createConfig({ items: ['a', 'b'] }); // items inferido como readonly ['a', 'b']
// @ts-expect-error Incompatibilidad entre readonly y mutable
mutateRoutes(config.items);
// ✅ CORRECTO: Clonación superficial mediante spread o tipado con readonly string[]
mutateRoutes([...config.items]);Si se define function parseList<const T>(items: T), TypeScript inferirá readonly [...], pero desconocerá que T se comporta como un iterable de array, bloqueando métodos como .map() o .length. Acompañe siempre el genérico con extends readonly unknown[].
// ❌ ERROR: El compilador no reconoce que T sea iterable
function processItems<const T>(items: T) {
// @ts-expect-error Property 'map' does not exist on type 'T'
return items.map((x) => String(x));
}
// ✅ CORRECTO: Delimitación explícita de tupla
function processItems<const T extends readonly unknown[]>(items: T) {
return items.map((x) => String(x));
}El modificador <const T> en TypeScript 5.0+ es un recurso imprescindible para creadores de librerías, marcos de trabajo y sistemas de diseño. Eleva exponencialmente la experiencia de desarrollo (DX) al deducir automáticamente literales de cadena y tuplas inmutables readonly directamente desde argumentos en línea, prescindiendo del operador as const. Tenga en cuenta las reglas doradas: enviar argumentos en línea, delimitar con readonly unknown[] y colocar const en firmas de funciones, no en declaraciones de clase.