TypeScript Indexed Access Types

An indexed access type lets you look up the type of a property on another type, using the exact same square-bracket syntax you’d use to read a value at runtime — but entirely at the type level. Instead of retyping a property’s shape by hand, you write Product["price"] and TypeScript hands you back whatever type that property already has. This keeps types in sync automatically: change the source interface, and every type derived from it via indexed access updates too, with zero duplication.

Overview: How Indexed Access Types Work

TypeScript’s type system treats an object type as something you can index into, just like a value. If T is a type and K is a key (or a union of keys) that exists on T, then T[K] is a valid type expression that evaluates to the type of the property (or properties) named by K. This is often called a "lookup type."

The key K doesn’t have to be a single string literal. It can be a union of string literals (giving you a union of the corresponding value types), the special number type (useful for extracting the element type of an array or tuple), or the keyof T operator (giving you the union of every property’s type on T). Because keyof T produces a union of all the property names of T, combining it with indexed access — T[keyof T] — is an extremely common pattern for saying "any value that could appear on this type."

Indexed access types are resolved entirely by the compiler during type checking. They participate in TypeScript’s structural type system: two types built via different indexed access expressions are compatible if the resulting shapes match, regardless of how you arrived at them. And because all TypeScript types are erased when compiling to JavaScript, none of this appears in the emitted output — the compiled JS simply reads product.price like any ordinary property access, with no trace of the type-level lookup that validated it.

Indexed access types are especially powerful when combined with keyof, mapped types, and generics, because they let you write utilities that derive new types from a single source of truth instead of hand-maintaining parallel type declarations that can drift out of sync.

Syntax

Type["propertyName"]        // type of a single named property
Type["propA" | "propB"]     // union of a subset of property types
Type[keyof Type]            // union of every property's type
Type[number]                // element type of an array or tuple
Type["outer"]["inner"]      // chained access into nested types
  • Type — any object type: an interface, a type alias for an object shape, a class instance type, an array type, or a tuple type.
  • the bracketed key — a string literal type naming a property, a union of string literal types, the keyof Type operator, or number for indexed collections.
  • chaining — you can index into the result of an indexed access again, walking down through nested object types one level at a time.
  • The key must actually exist on Type (or be assignable to its index signature); referencing a property that isn’t there is a compile-time error, not undefined at runtime.

Examples

Example 1: Extracting a single property’s type

interface Product {
  id: number;
  name: string;
  price: number;
  tags: string[];
}

type ProductName = Product["name"];
type ProductPrice = Product["price"];

const name: ProductName = "Wireless Mouse";
const price: ProductPrice = 29.99;

console.log(name, price);

Output:

Wireless Mouse 29.99

Here Product["name"] evaluates to string and Product["price"] evaluates to number, exactly matching the property declarations on Product. If price later changed to allow number | null, ProductPrice would automatically pick up that change — no separate type to update.

Example 2: Combining with keyof for a value union

interface Config {
  host: string;
  port: number;
  secure: boolean;
}

type ConfigValue = Config[keyof Config];

function logConfigValue(value: ConfigValue): void {
  console.log("Value:", value);
}

logConfigValue("localhost");
logConfigValue(8080);
logConfigValue(true);

Output:

Value: localhost
Value: 8080
Value: true

keyof Config is the union "host" | "port" | "secure". Indexing Config with that whole union produces the union of all three property types: string | number | boolean. This is exactly the pattern used internally by utility types like Record and by generic helper functions that need to accept "any value this object could hold."

Example 3: Array element types and nested access

interface Order {
  id: number;
  items: {
    sku: string;
    quantity: number;
  }[];
  customer: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
}

type OrderItem = Order["items"][number];
type CustomerCity = Order["customer"]["address"]["city"];

const item: OrderItem = { sku: "ABC-123", quantity: 2 };
const city: CustomerCity = "Springfield";

function describeItem(orderItem: OrderItem): string {
  return `${orderItem.quantity}x ${orderItem.sku}`;
}

console.log(describeItem(item));
console.log(city);

Output:

2x ABC-123
Springfield

Order["items"] is an array type, so indexing it again with number yields the type of a single element — this is the standard idiom for pulling the element type out of an array or tuple without redeclaring the shape. CustomerCity shows that indexed access chains: each bracket step walks one level deeper into the nested object structure, ending at the innermost string property.

Under the Hood: Step by Step

When the compiler encounters T[K], it performs roughly these steps:

  • It resolves T to its full structural shape (its set of known properties and their types, plus any index signatures).
  • It resolves K to a concrete key or set of keys — a string literal, a union of literals, number, or the result of expanding keyof T.
  • For each resolved key, it looks up the corresponding property type on T‘s shape. If a key doesn’t exist and T has no compatible index signature, this is a compile error.
  • If K was a union, the compiler distributes the lookup over each member and combines the results into a union type.
  • The resulting type is substituted wherever T[K] appeared, and ordinary type checking (assignability, inference, narrowing) proceeds using that substituted type.

All of this happens purely during type checking. Once the code compiles to JavaScript, every type annotation, interface, and indexed access expression is stripped away — the emitted JS is just plain property access (order.items[0], config.port, etc.). Indexed access types cost nothing at runtime; they exist solely to keep the compiler honest about what shapes flow through your program.

Common Mistakes

Mistake 1: Indexing a property that doesn’t exist

interface User {
  id: number;
  email: string;
}

type UserRole = User["role"];

This fails to compile with an error similar to Property 'role' does not exist on type 'User', because User was never declared with a role property. Indexed access types are not optional lookups — the key must be a real member of the type (or covered by an index signature).

Corrected version, once role is actually part of the interface:

interface User {
  id: number;
  email: string;
  role: "admin" | "member";
}

type UserRole = User["role"];

const role: UserRole = "admin";
console.log(role);

Output:

admin

Mistake 2: Forgetting that optional properties include undefined

interface Settings {
  theme: "light" | "dark";
  timeout?: number;
}

type Timeout = Settings["timeout"];

function setTimeoutValue(value: Timeout): void {
  console.log(value + 100);
}

Because timeout is optional, Settings["timeout"] is number | undefined, not just number. Under strict mode, value + 100 fails with an error like Object is possibly 'undefined', since arithmetic isn’t defined on undefined.

Corrected version, handling the undefined case explicitly:

interface Settings {
  theme: "light" | "dark";
  timeout?: number;
}

type Timeout = Settings["timeout"];

function setTimeoutValue(value: Timeout): void {
  const resolved = value ?? 30;
  console.log(resolved + 100);
}

setTimeoutValue(undefined);
setTimeoutValue(500);

Output:

130
600

Best Practices

  • Prefer Type["prop"] over manually copying a property’s type — it stays correct automatically as the source type evolves.
  • Use Type[keyof Type] when you need "any value this object could hold," instead of writing out the union of value types by hand.
  • Use ArrayOrTupleType[number] as the idiomatic way to extract an element type from an array or tuple.
  • Remember optional properties (prop?: X) surface as X | undefined through indexed access — handle that union explicitly rather than asserting it away.
  • Chain indexed access (Type["a"]["b"]) to drill into nested shapes instead of re-declaring inner interfaces separately.
  • Combine indexed access with generics (for example function get<T, K extends keyof T>(obj: T, key: K): T[K]) when you need a reusable, type-safe property accessor.

Practice Exercises

  • Define an interface Invoice with id: number, amount: number, and status: "draft" | "sent" | "paid". Use indexed access to declare a type InvoiceStatus equal to the type of the status property, then write a function that accepts only that type and logs it.
  • Given interface Catalog { books: { title: string; year: number }[]; }, use indexed access (twice, chained) to derive a type representing a single book, and write a function that formats a book as "Title (Year)".
  • Given an interface with several properties of mixed types, build a type using Type[keyof Type] representing the union of all its value types, then write a function that accepts that union and uses a typeof check to branch on the runtime type before logging it.

Summary

  • Indexed access types use the syntax Type["key"] to read off the type of a property from an existing type, keeping derived types automatically in sync with their source.
  • The key can be a single string literal, a union of literals, keyof Type (all property types as a union), or number (element type of an array or tuple).
  • Indexed access chains: Type["a"]["b"] walks into nested object shapes one level at a time.
  • Optional properties surface as T | undefined through indexed access — handle that explicitly under strict mode.
  • All of this is compile-time only; indexed access types are fully erased and have zero runtime cost.