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 Tconstrainskeyto only accept actual property names ofobj.- 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"returnsnumber. Calling with"status"returns the literal union. There’s noanyanywhere.
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?) includeundefinedin 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 OrderEventsconstrains event names to"created" | "paid" | "cancelled".OrderEvents[K]ties the payload to the correct event. In a"paid"handler, the parameterphasmethodbut notreason.- The mapped type
[K in keyof OrderEvents]creates a store for handlers, with each event having its own list. - To add a
refundedevent, you only modifyOrderEvents. 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:
expectTypeOfonly works when types are checked via--typecheckortsc. At runtime, it does almost nothing. If you run justvitest runwithout 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 onInvoice. IfAddressis 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:
keyofextracts the keys of a type. It’s most useful as a constraintK extends keyof T.typeofin a type position turns a value into a type. Paired withas 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.