keyof, typeof y tipos de acceso indexado en TypeScript
1. Descripción general
A medida que la base de código crece, te encontrarás con el mismo conjunto de claves, como los estados de un pedido, apareciendo en tres o cuatro lugares: dentro de objetos, en tipos union, en parámetros de funciones. Olvidar actualizar un lugar mientras modificas otro resulta en un bug.
TypeScript tiene tres herramientas para declarar algo una sola vez e inferir el resto. keyof obtiene el conjunto de claves de un tipo. typeof obtiene un tipo desde un valor. El tipo de acceso indexado (T["key"]) obtiene el tipo de una propiedad.
Después de leer este artículo, sabrás cómo combinar estas tres herramientas para escribir funciones genéricas seguras y construir un bus de eventos con tipos estrictos. También conocerás los errores comunes al usarlas.
2. Preparación
Este artículo usa TypeScript 5.x y Vitest para escribir pruebas. Instálalos así:
npm install --save-dev typescript vitest
Deberías habilitar strict en tsconfig.json. Si no lo haces, muchos errores sobre undefined no serán reportados:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. El operador keyof
3.1. Uso básico
keyof T devuelve una unión con los nombres de las propiedades de T:
type Order = {
id: string;
total: number;
status: "pending" | "paid" | "cancelled";
note?: string;
};
type OrderKey = keyof Order;
// "id" | "total" | "status" | "note"
const sortBy: OrderKey = "total"; // OK
const wrong: OrderKey = "price"; // Error
La última línea no compila:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
No tienes que escribir manualmente la unión "id" | "total" | .... Cuando añades una nueva propiedad a Order, OrderKey se actualiza automáticamente.
3.2. keyof con index signature
Si el tipo usa un index signature, el resultado puede sorprenderte:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey es string | number y no solo string. La razón es que en JavaScript, obj[1] y obj["1"] acceden a la misma propiedad. Por eso TypeScript permite claves de tipo number.
3.3. keyof en genéricos
keyof es más poderoso cuando se usa con restricciones genéricas:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
const total = getProperty(order, "total"); // number
const status = getProperty(order, "status"); // "pending" | "paid" | "cancelled"
getProperty(order, "price"); // Error de compilación
Esta función hace varias cosas:
K extends keyof Trestringekey, así solo acepta nombres de propiedades reales deobj.- El tipo de retorno
T[K]es un tipo de acceso indexado, que se explica en la sección 5. Gracias a él, cada llamada a la función obtiene el tipo correcto de la propiedad correspondiente. - Llamar con
"total"devuelvenumber. Llamar con"status"devuelve una unión literal. No hayanyaquí.
4. El operador typeof en contexto de tipos
JavaScript tiene typeof que se ejecuta en tiempo de ejecución y devuelve cadenas como "string" u "object". TypeScript añade un segundo significado: cuando se coloca en una posición de tipo, typeof x te da el tipo de la variable x.
4.1. Obtener un tipo desde un valor
const defaultCustomer = {
name: "Juan García",
email: "juan@example.com",
loyaltyPoints: 0,
};
type Customer = typeof defaultCustomer;
// { name: string; email: string; loyaltyPoints: number }
function createCustomer(input: Partial<Customer>): Customer {
return { ...defaultCustomer, ...input };
}
console.log(createCustomer({ name: "María López" }));
Resultado:
{ name: 'María López', email: 'juan@example.com', loyaltyPoints: 0 }
Aquí el valor predeterminado es la única fuente de verdad. Cuando añades un campo a defaultCustomer, Customer también lo tiene.
Con funciones, combinas typeof con tipos utilitarios incorporados:
function loadConfig() {
return { apiUrl: "https://api.shop.local", timeoutMs: 5000, retries: 3 };
}
type AppConfig = ReturnType<typeof loadConfig>;
// { apiUrl: string; timeoutMs: number; retries: number }
4.2. as const y typeof
Por defecto TypeScript “amplía” los literales: "pending" se convierte en string, arrays en string[]. Añadir as const mantiene el literal y marca como readonly:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
Sin as const, typeof ORDER_STATUSES sería solo string[] y perderías toda la información del literal.
4.3. Combinando keyof typeof
Esta es una forma muy común de escribir cuando tienes un objeto constante y quieres obtener la unión de sus claves:
const SHIPPING_FEES = {
standard: 30_000,
express: 60_000,
sameDay: 120_000,
} as const;
type ShippingMethod = keyof typeof SHIPPING_FEES;
// "standard" | "express" | "sameDay"
function shippingFee(method: ShippingMethod): number {
return SHIPPING_FEES[method];
}
console.log(shippingFee("express"));
60000
Léelo de adentro hacia afuera. typeof SHIPPING_FEES obtiene el tipo del objeto, luego keyof obtiene las claves de ese tipo. No puedes escribir keyof SHIPPING_FEES porque SHIPPING_FEES es un valor, no un tipo.
5. Tipos de acceso indexado
5.1. Obtener el tipo de una propiedad
La sintaxis T["key"] se parece a acceder a una propiedad, pero ocurre en la capa de tipos:
type OrderStatus = Order["status"]; // "pending" | "paid" | "cancelled"
type OrderNote = Order["note"]; // string | undefined
type IdOrTotal = Order["id" | "total"]; // string | number
type AnyOrderValue = Order[keyof Order]; // union type de todos los valores
Hay tres puntos a tener en cuenta:
- Una propiedad opcional (
note?) produce un tipo que incluyeundefined. - Pasar una unión de claves devuelve la unión de los tipos correspondientes.
T[keyof T]es la forma habitual de obtener el “tipo de todo valor” en un objeto.
5.2. Obtener el tipo del elemento de un array
Indexar con number te da el tipo del elemento:
type OrderStatusFromList = (typeof ORDER_STATUSES)[number];
// "pending" | "paid" | "shipped" | "cancelled"
function isOrderStatus(value: string): value is OrderStatusFromList {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
console.log(isOrderStatus("paid"), isOrderStatus("refunded"));
true false
El array ORDER_STATUSES ahora sirve para dos cosas. En tiempo de ejecución, es una lista para validar entradas de la API. En tiempo de compilación, genera un tipo union. Solo declaras el estado una vez.
5.3. Acceso anidado
Puedes encadenar múltiples índices para profundizar en la estructura:
type Invoice = {
customer: { name: string; address: { city: string; zip: string } };
lines: Array<{ sku: string; quantity: number; unitPrice: number }>;
};
type City = Invoice["customer"]["address"]["city"]; // string
type InvoiceLine = Invoice["lines"][number];
// { sku: string; quantity: number; unitPrice: number }
function lineTotal(line: InvoiceLine): number {
return line.quantity * line.unitPrice;
}
InvoiceLine se obtiene directamente de Invoice, así no necesitas declarar un tipo separado y mantener ambos sincronizados.
6. Ejemplo práctico: un bus de eventos con tipos estrictos
Ahora vamos a combinar las tres herramientas. El objetivo es un bus de eventos donde el payload de cada evento sea verificado con el tipo correcto:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; method: "card" | "cash" };
cancelled: { orderId: string; reason: string };
};
type Handler<K extends keyof OrderEvents> = (payload: OrderEvents[K]) => void;
export class OrderEventBus {
private handlers: { [K in keyof OrderEvents]?: Handler<K>[] } = {};
on<K extends keyof OrderEvents>(event: K, handler: Handler<K>): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
this.handlers[event]?.forEach((handle) => handle(payload));
}
}
const bus = new OrderEventBus();
bus.on("paid", (p) => console.log(`Pedido ${p.orderId} pagado con ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Error: falta 'method'
// bus.emit("refunded", { orderId: "ORD-42" }); // Error: evento no existe
Salida:
Pedido ORD-42 pagado con card
Cómo funciona cada parte:
keyof OrderEventsrestringe los nombres de evento a tres valores:"created" | "paid" | "cancelled".OrderEvents[K]vincula el payload al evento correcto. En el manejador de"paid", el parámetroptienemethodpero noreason.- El tipo mapeado
[K in keyof OrderEvents]crea un almacén para los manejadores, cada evento tiene su propia lista. - Para añadir el evento
refunded, solo modificasOrderEvents. Todo lo demás se actualiza automáticamente.
7. Pruebas de tipos con Vitest
Vitest tiene expectTypeOf para verificar tipos. Lo combinas con @ts-expect-error para asegurar que el código incorrecto sea bloqueado:
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { OrderEventBus } from "./order-event-bus";
type Order = { id: string; total: number; status: "pending" | "paid" };
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
describe("keyof y tipos de acceso indexado", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("devuelve el valor correcto y el tipo correcto", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("rechaza claves inexistentes", () => {
// @ts-expect-error - 'price' no es una clave de Order
getProperty(order, "price");
});
it("el bus de eventos llama al manejador con el payload correcto", () => {
const bus = new OrderEventBus();
const handler = vi.fn();
bus.on("created", handler);
bus.emit("created", { orderId: "ORD-7", total: 99_000 });
expect(handler).toHaveBeenCalledWith({ orderId: "ORD-7", total: 99_000 });
});
});
Ejecutas las pruebas con dos comandos:
npx vitest run --typecheck
npx tsc --noEmit
Nota:
expectTypeOfsolo tiene efecto cuando los tipos se verifican, a través de--typecheckotsc. En tiempo de ejecución, casi no hace nada. Si solo ejecutasvitest runnormalmente, las pruebas pasarán incluso si los tipos son incorrectos.
Si alguien elimina "price" de la lista de errores, por ejemplo añadiendo price a Order, @ts-expect-error reportará “Unused ‘@ts-expect-error’ directive”. Esta es una buena señal: tus pruebas de tipos están protegiendo el contrato de la API.
8. Errores comunes
Usar una variable como índice de tipo. Con const key = "total", escribir Order[key] reportará un error:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
La forma de corregirlo es escribir Order[typeof key].
Llamar typeof en una expresión. typeof loadConfig() es un error de sintaxis. typeof solo acepta un identificador o acceso a una propiedad como typeof config.db. Para el resultado de una función, usas ReturnType<typeof loadConfig>.
Creer que Object.keys devuelve keyof T. De hecho devuelve string[]:
for (const key of Object.keys(order)) {
// order[key]; // Error: 'string' no puede indexar 'Order'
}
TypeScript lo hace intencionalmente. Los tipos son “estructurales”, así que en tiempo de ejecución un objeto puede contener más propiedades que las que declara el tipo. Si estás seguro sobre los datos, puedes hacer un cast a Object.keys(order) as (keyof Order)[]. Pero entiende que estás asumiendo una responsabilidad en lugar del compilador.
Olvidar as const. Sin él, (typeof ORDER_STATUSES)[number] es solo string, y todos los beneficios de la unión literal desaparecen sin que se reporte error alguno.
Nota: No abuses. Cadenas como
Invoice["customer"]["address"]["city"]tienen sentido cuando realmente quieres que el tipo dependa deInvoice. SiAddresses un concepto independiente usado en muchos lugares, decláralo como un tipo con nombre. El código será más legible y los mensajes de error más concisos.
9. Conclusión
Las tres herramientas en este artículo hacen tres cosas diferentes:
keyofobtiene el conjunto de claves de un tipo. Es más útil cuando se usa como restricciónK extends keyof T.typeofen posición de tipo convierte un valor en un tipo. Cuando se usa conas const, ayuda que arrays y objetos constantes se conviertan en la única fuente de verdad.- El tipo de acceso indexado
T[K]obtiene el tipo de una propiedad, un elemento de array ([number]) o una estructura anidada.
Cuando los combinas, puedes escribir funciones genéricas y buses de eventos donde el compilador atrapa errores de claves incorrectas o payloads incorrectos en tiempo de compilación. Tampoco necesitas repetir la misma lista en varios lugares. Recuerda habilitar strict, ejecutar tsc o vitest --typecheck en CI, y solo usar cadenas de índices largas cuando los tipos realmente dependan unos de otros.