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 T restringe key, então ele aceita apenas nomes de propriedade reais de obj.
  • 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ê recebe number. Ao chamar com "status", você recebe um tipo union literal. Sem nenhum any aqui.

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 inclui undefined.
  • 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 OrderEvents restringe 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âmetro p tem method mas não reason.
  • 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 modifica OrderEvents. 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: expectTypeOf só tem efeito quando os tipos são verificados via --typecheck ou tsc. Em tempo de execução, ele praticamente não faz nada. Se você apenas executar vitest run normalmente, 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 de Invoice. Se Address é 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:

  • keyof obtém o conjunto de chaves de um tipo. É mais útil ao criar a constraint K extends keyof T.
  • typeof em posição de tipo converte um valor em tipo. Quando combinado com as 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.

typescriptsistema de tiposkeyoftypeofgenerics