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 T restringe key, así solo acepta nombres de propiedades reales de obj.
  • 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" devuelve number. Llamar con "status" devuelve una unión literal. No hay any aquí.

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 incluye undefined.
  • 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 OrderEvents restringe 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ámetro p tiene method pero no reason.
  • 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 modificas OrderEvents. 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: expectTypeOf solo tiene efecto cuando los tipos se verifican, a través de --typecheck o tsc. En tiempo de ejecución, casi no hace nada. Si solo ejecutas vitest run normalmente, 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 de Invoice. Si Address es 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:

  • keyof obtiene el conjunto de claves de un tipo. Es más útil cuando se usa como restricción K extends keyof T.
  • typeof en posición de tipo convierte un valor en un tipo. Cuando se usa con as 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.

typescripttype systemkeyoftypeofgenerics