keyof, typeof e tipos com acesso indexado no TypeScript
1. Tópico geral
À medida que sua base de código cresce, você encontrará o mesmo conjunto de chaves, como status de pedido, aparecendo em três ou quatro lugares: em um objeto, em um tipo union, em parâmetros de função. Modificar um lugar e esquecer outro resulta em bug.
TypeScript tem três ferramentas para declarar uma única vez e depois inferir o resto. keyof obtém o conjunto de chaves de um tipo. typeof obtém o tipo de um valor. Indexed access type (T["key"]) obtém o tipo de uma propriedade.
Depois de ler este artigo, você saberá como combinar essas três ferramentas para escrever funções genéricas seguras e construir um event bus com tipos bem definidos. Você também aprenderá os erros comuns ao usá-las.
2. Preparação
Este artigo usa TypeScript 5.x e Vitest para escrever testes. Instale da seguinte forma:
npm install --save-dev typescript vitest
Você deve habilitar strict em tsconfig.json. Se não habilitar, muitos erros sobre undefined não serão reportados:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. Operador keyof
3.1. Uso básico
keyof T retorna uma união com os nomes de todas as propriedades 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"; // Erro
A última linha não compila:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
Você não precisa digitar manualmente a união "id" | "total" | .... Quando adiciona uma nova propriedade a Order, OrderKey se atualiza automaticamente.
3.2. keyof com index signature
Se um tipo usa index signature, o resultado pode surpreendê-lo:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey é string | number e não apenas string. A razão é que em JavaScript, obj[1] e obj["1"] acessam a mesma propriedade. Por isso TypeScript permite chaves do tipo number.
3.3. keyof em genéricos
keyof funciona melhor quando combinado com constraint genérico:
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"); // Erro de compilação
Esta função faz várias coisas:
K extends keyof Trestringekey, então ele aceita apenas nomes de propriedade reais deobj.- O tipo de retorno
T[K]é um indexed access type, que será explicado na seção 5. Graças a ele, cada chamada da função recebe o tipo correto da propriedade correspondente. - Ao chamar com
"total", você recebenumber. Ao chamar com"status", você recebe um tipo union literal. Sem nenhumanyaqui.
4. O operador typeof no contexto de tipo
JavaScript tem typeof que executa em tempo de execução e retorna uma string como "string" ou "object". TypeScript adiciona um segundo significado: quando usado em posição de tipo, typeof x fornece o tipo da variável x.
4.1. Obtendo tipo de um valor
const defaultCustomer = {
name: "Nguyễn Văn An",
email: "an@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: "Trần Thị Bình" }));
Resultado:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
Aqui, o valor padrão é a única fonte de verdade. Se você adicionar um campo a defaultCustomer, Customer também terá esse campo.
Com funções, você combina typeof com tipos utilitários fornecidos:
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 e typeof
Por padrão, TypeScript “expande” literal: "pending" se torna string, arrays se tornam string[]. Adicionar as const mantém o literal e marca como readonly:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
Se você omitir as const, typeof ORDER_STATUSES será apenas string[] e você perde toda a informação de literal.
4.3. Combinando keyof typeof
Este é um padrão muito comum quando você tem um objeto constante e quer obter uma união das suas chaves:
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
A forma de ler é de dentro para fora. typeof SHIPPING_FEES obtém o tipo do objeto e depois keyof obtém as chaves desse tipo. Você não pode escrever keyof SHIPPING_FEES porque SHIPPING_FEES é um valor, não um tipo.
5. Indexed access types
5.1. Obtendo o tipo de uma propriedade
A sintaxe T["key"] parece o acesso a uma propriedade, mas ocorre no nível de tipo:
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]; // tipo union de todas as propriedades
Há três pontos a serem observados:
- Uma propriedade opcional (
note?) produz um tipo que incluiundefined. - Passar uma união de chaves retorna uma união dos tipos correspondentes.
T[keyof T]é a forma comum de obter “o tipo de todos os valores” no objeto.
5.2. Obtendo o tipo de elemento do array
Usar índice number fornecerá o tipo do 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
O array ORDER_STATUSES agora serve dois propósitos. Em tempo de execução, é uma lista para validar entrada da API. Em tempo de compilação, gera um tipo union. Você declara o status apenas uma vez.
5.3. Acesso aninhado
Você pode encadear múltiplos índices para ir mais fundo na estrutura:
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 é extraído diretamente de Invoice, então você não precisa declarar um tipo separado e manter os dois em sincronia.
6. Exemplo prático: event bus com tipos bem definidos
Agora combinamos todas as três ferramentas. O objetivo é um event bus onde o payload de cada evento é verificado com o tipo correto:
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} pago com ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Erro: falta 'method'
// bus.emit("refunded", { orderId: "ORD-42" }); // Erro: evento não existe
Saída:
Pedido ORD-42 pago com card
Cada parte funciona como segue:
keyof OrderEventsrestringe nomes de eventos a esses três valores:"created" | "paid" | "cancelled".OrderEvents[K]vincula o payload ao evento correto. No handler de"paid", o parâmetroptemmethodmas nãoreason.- O mapped type
[K in keyof OrderEvents]cria um repositório de handlers, cada evento tem sua própria lista. - Para adicionar o evento
refunded, você apenas modificaOrderEvents. Todo o resto se atualiza automaticamente.
7. Testando tipos com Vitest
Vitest tem expectTypeOf para verificar tipos. Você o combina com @ts-expect-error para garantir que código incorreto seja 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 e acesso indexado", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("retorna valor correto e tipo correto", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("rejeita chave inexistente", () => {
// @ts-expect-error - 'price' não é uma chave de Order
getProperty(order, "price");
});
it("event bus chama handler com payload correto", () => {
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 });
});
});
Você executa os testes com dois comandos:
npx vitest run --typecheck
npx tsc --noEmit
Nota:
expectTypeOfsó tem efeito quando os tipos são verificados via--typecheckoutsc. Em tempo de execução, ele praticamente não faz nada. Se você apenas executarvitest runnormalmente, o teste ainda passa mesmo se o tipo estiver errado.
Se alguém remover "price" da lista de erros, por exemplo adicionando price a Order, então @ts-expect-error reportará “Unused ‘@ts-expect-error’ directive”. Este é um bom sinal: seu teste de tipo está protegendo seu contrato de API.
8. Erros comuns
Usar variável como índice de tipo. Com const key = "total", escrever Order[key] reportará um erro:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
A forma de corrigir é escrever Order[typeof key].
Chamar typeof em uma expressão. typeof loadConfig() é um erro de sintaxe. typeof apenas aceita um identificador ou acesso de propriedade como typeof config.db. Para o resultado de uma função, use ReturnType<typeof loadConfig>.
Pensar que Object.keys retorna keyof T. Na verdade, retorna string[]:
for (const key of Object.keys(order)) {
// order[key]; // Erro: 'string' não indexa 'Order'
}
TypeScript faz isso propositalmente. Tipos são “estruturais”, então em tempo de execução um objeto pode ter mais propriedades do que o tipo declara. Se você tem certeza sobre os dados, pode fazer type cast Object.keys(order) as (keyof Order)[]. Mas entenda que você está se comprometendo em nome do compilador.
Esquecer as const. Sem ele, (typeof ORDER_STATUSES)[number] é apenas string e todos os benefícios do tipo union literal desaparecem sem nenhum erro ser reportado.
Nota: Não abuse. Cadeias como
Invoice["customer"]["address"]["city"]fazem sentido quando você realmente quer que o tipo dependa deInvoice. SeAddressé um conceito independente usado em muitos lugares, declare-o como um tipo nomeado. O código será mais legível e as mensagens de erro mais concisas.
9. Conclusão
As três ferramentas neste artigo fazem três coisas diferentes:
keyofobtém o conjunto de chaves de um tipo. É mais útil ao criar a constraintK extends keyof T.typeofem posição de tipo converte um valor em tipo. Quando combinado comas const, ajuda arrays e objetos constantes a se tornarem a única fonte de verdade.- Indexed access type
T[K]obtém o tipo de uma propriedade, elemento de array ([number]) ou estrutura aninhada.
Quando combinadas, você pode escrever funções genéricas e um event bus onde o compilador captura erros de chaves ou payloads incorretos no momento da compilação. Você também não precisa repetir a mesma lista em muitos lugares. Lembre-se de habilitar strict, executar tsc ou vitest --typecheck em seu CI, e use apenas cadeias de índices longas quando o tipo realmente depender uns dos outros.