keyof, typeof, and indexed access types in TypeScript

1. Overview

As your codebase grows, you’ll encounter the same set of keys—for example, order statuses—appearing in three or four places: in objects, in union types, in function parameters. Fixing one place and forgetting another is a common source of bugs.

TypeScript has three tools to let you declare once and infer the rest. keyof extracts the keys of a type. typeof extracts a type from a value. Indexed access type (T["key"]) extracts the type of a property.

After reading this post, you’ll know how to combine these three tools to write type-safe generic functions and build a strongly-typed event bus. You’ll also learn common pitfalls when using them.

2. Setup

This guide uses TypeScript 5.x and Vitest for testing. Install them as follows:

npm install --save-dev typescript vitest

You should enable strict mode in tsconfig.json. Without it, many errors related to undefined won’t be reported:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

3. The keyof operator

3.1. Basic usage

keyof T returns a union of the property names of 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

The last line fails to compile:

error TS2322: Type '"price"' is not assignable to type 'keyof Order'.

You don’t have to manually type the union "id" | "total" | .... When you add a new property to Order, OrderKey updates automatically.

3.2. keyof with index signatures

If your type uses an index signature, the result might surprise you:

type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number

type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex;    // number

PriceKey is string | number, not just string. This is because in JavaScript, obj[1] and obj["1"] access the same property. Therefore, TypeScript allows both number and string keys.

3.3. keyof with generics

keyof is most powerful when used with generic constraints:

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");                 // Compile error

This function does several things:

  • K extends keyof T constrains key to only accept actual property names of obj.
  • The return type T[K] is an indexed access type, explained in section 5. Thanks to it, each function call receives the correct type for the corresponding property.
  • Calling with "total" returns number. Calling with "status" returns the literal union. There’s no any anywhere.

4. The typeof operator in type context

JavaScript has typeof as a runtime operator that returns strings like "string" or "object". TypeScript adds a second meaning: when used in a type position, typeof x gives you the type of the variable x.

4.1. Extracting a type from a value

const defaultCustomer = {
  name: "John Doe",
  email: "john@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: "Jane Smith" }));

Result:

{ name: 'Jane Smith', email: 'john@example.com', loyaltyPoints: 0 }

Here, the default value is the single source of truth. When you add a field to defaultCustomer, Customer gets that field too.

With functions, you combine typeof with built-in utility types:

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 with typeof

By default, TypeScript “widens” literals: "pending" becomes string, an array becomes string[]. Adding as const preserves the literals and marks them as readonly:

const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;

type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]

Without as const, typeof ORDER_STATUSES is just string[], and you lose all literal information.

4.3. Combining keyof and typeof

This is a very common pattern when you have a constant object and want to extract a union of its keys:

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

Read this from the inside out. typeof SHIPPING_FEES extracts the type of the object, then keyof extracts the keys of that type. You can’t write keyof SHIPPING_FEES directly because SHIPPING_FEES is a value, not a type.

5. Indexed access types

5.1. Extracting the type of a property

The syntax T["key"] looks like property access, but happens at the type level:

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 of all property types

There are three things to note:

  • Optional properties (note?) include undefined in their type.
  • Passing a union of keys returns a union of the corresponding types.
  • T[keyof T] is the standard way to get the “type of any value” in an object.

5.2. Extracting the type of an array element

Indexing with number gives you the element type:

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

The ORDER_STATUSES array now serves two purposes. At runtime, it’s a list for validating input from an API. At compile time, it generates a union type. You declare the statuses only once.

5.3. Nested access

You can chain multiple indexes to traverse nested structures:

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 is extracted directly from Invoice, so you don’t need a separate type definition and don’t have to keep two things in sync.

6. Real-world example: a strongly-typed event bus

Now let’s combine all three tools. The goal is an event bus where each event’s payload is type-checked:

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(`Order ${p.orderId} paid with ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });

// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Error: missing 'method'
// bus.emit("refunded", { orderId: "ORD-42" });          // Error: event doesn't exist

Output:

Order ORD-42 paid with card

Here’s how each part works:

  • keyof OrderEvents constrains event names to "created" | "paid" | "cancelled".
  • OrderEvents[K] ties the payload to the correct event. In a "paid" handler, the parameter p has method but not reason.
  • The mapped type [K in keyof OrderEvents] creates a store for handlers, with each event having its own list.
  • To add a refunded event, you only modify OrderEvents. Everything else updates automatically.

7. Testing types with Vitest

Vitest has expectTypeOf for type checking. Combine it with @ts-expect-error to ensure incorrect code is caught:

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 and indexed access", () => {
  const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };

  it("returns correct value and correct type", () => {
    const total = getProperty(order, "total");

    expect(total).toBe(250_000);
    expectTypeOf(total).toEqualTypeOf<number>();
    expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
  });

  it("rejects non-existent keys", () => {
    // @ts-expect-error - 'price' is not a key of Order
    getProperty(order, "price");
  });

  it("event bus calls handler with correct payload", () => {
    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 });
  });
});

Run tests with these two commands:

npx vitest run --typecheck
npx tsc --noEmit

Note: expectTypeOf only works when types are checked via --typecheck or tsc. At runtime, it does almost nothing. If you run just vitest run without typechecking, tests pass even if types are wrong.

If someone removes "price" from the error list—say, by adding price to Order—then @ts-expect-error will report “Unused ‘@ts-expect-error’ directive”. That’s a good sign: your type tests are protecting your API contract.

8. Common mistakes

Using a variable as a type index. With const key = "total", writing Order[key] produces an error:

error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?

The fix is to write Order[typeof key].

Calling typeof on an expression. typeof loadConfig() is a syntax error. typeof only accepts identifiers or property access like typeof config.db. For a function’s return type, use ReturnType<typeof loadConfig>.

Thinking Object.keys returns keyof T. In reality, it returns string[]:

for (const key of Object.keys(order)) {
  // order[key]; // Error: 'string' cannot index 'Order'
}

TypeScript does this intentionally. Types are “structural,” so at runtime an object might have more properties than the type declares. If you’re sure about the data, you can cast: Object.keys(order) as (keyof Order)[]. But understand that you’re making a promise to the compiler.

Forgetting as const. Without it, (typeof ORDER_STATUSES)[number] becomes just string, and all the benefits of literal unions disappear without any error message.

Note: Don’t overuse this. A chain like Invoice["customer"]["address"]["city"] makes sense when you truly want the type to depend on Invoice. If Address is an independent concept used in multiple places, declare it as a named type. Your code will be more readable, and error messages will be clearer.

9. Conclusion

The three tools in this guide do different jobs:

  • keyof extracts the keys of a type. It’s most useful as a constraint K extends keyof T.
  • typeof in a type position turns a value into a type. Paired with as const, it makes constant arrays and objects your single source of truth.
  • Indexed access type T[K] extracts the type of a property, an array element ([number]), or nested structures.

Combined together, you can write generic functions and event buses where the compiler catches incorrect keys and payloads at compile time. You don’t have to repeat the same list in multiple places. Remember to enable strict, run tsc or vitest --typecheck in CI, and only use long index chains when types truly depend on each other.

typescripttype systemkeyoftypeofgenerics