TypeScript Indexed Access Types: Deriving Types from Existing Ones with T["key"]
We look at reading a property's type with T["key"], union indexes, getting an array's element type with T[number], T[K] in generics and deriving pieces from large API types.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We have a simple User type. The three lines below it try to pull other types out of it:
type User = {
id: number;
name: string;
address: { city: string; zip: string };
};
type A = User["address"]["city"];
type B = User["id" | "name"];
type C = User["email"];
Three questions: which type does A resolve to? What is B? Does the C line compile? We'll answer them one by one as the post goes on.
Asking for a Property's Type by Name
The keyof operator we saw earlier gave us a type's keys. An indexed access type does the opposite: you give it a key and get back that key's value type. The syntax looks a lot like reading a property with square brackets in JavaScript, except it works on types instead of values: User["name"] means "the type of the name property inside User", and the result is string.
Let's see why this is useful by looking at repeated types:
type Order = {
id: string;
status: "pending" | "paid" | "shipped";
customer: { id: string; email: string };
};
function setStatus(order: Order, status: Order["status"]) {
order.status = status;
}
function sendReceipt(email: Order["customer"]["email"]) {
console.log(`Receipt sent to ${email}`);
}
sendReceipt("ada@example.com"); // Receipt sent to ada@example.com
In setStatus we didn't write the "pending" | "paid" | "shipped" union a second time; we asked Order for it. When someone adds "cancelled" to that union tomorrow, the function's parameter widens on its own. Had you copied it by hand, you would have to remember to update both places together.
Think of it like a spreadsheet. If you type another cell's value into a cell by hand, your cell goes stale when the source changes. If you reference it with =B2, it's always current. Order["status"] is that reference.
The sendReceipt example also answers the first opening question: indexed access can be chained. User["address"] gives you an object type, and you can ask that for its ["city"]. So A is string.
Using a Union as the Index
You don't have to put a single key inside the square brackets; you can pass a union too. TypeScript asks for each key separately and combines the results. User["id" | "name"] is the same as User["id"] | User["name"], which is number | string. That's the answer to the second question.
The most common form of this is combining it with keyof: T[keyof T] gives you the union of every value type in a type.
type Settings = {
theme: "light" | "dark";
fontSize: number;
autoSave: boolean;
};
type SettingValue = Settings[keyof Settings];
// "light" | "dark" | number | boolean
function updateSettingLoose(key: keyof Settings, value: SettingValue) {
console.log(key, value);
}
updateSettingLoose("theme", 14); // compiles, but makes no sense
Look at the last line. SettingValue is a correct type, but the function has no link between the key and the value. As far as TypeScript is concerned, "theme" and 14 are each valid on their own; that they make no sense together isn't its concern. We'll see how to build that link with generics shortly.
The Index Has to Be a Type
On to the third question. There's no email property in User, and TypeScript catches it just as it would if you read the wrong property on a value:
type User = { id: number; name: string };
// Error: Property 'email' does not exist on type 'User'.
type Email = User["email"];
// Error: Cannot access 'User.name' because 'User' is a type, but not a namespace.
type Name = User.name;
const key = "name";
// Error: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
type ByKey = User[key];
type ByKeyFixed = User[typeof key]; // string
So the C line doesn't compile; the error code is TS2339. There are two more traps in this block. The first is dot syntax: you can't write User.name at the type level; indexed access is always written with square brackets. In this case the compiler even suggests the correct form at the end of the message.
The second matters more: what goes inside the square brackets is a type, not a value. When you write "name", you are really passing the "name" literal type. key, on the other hand, is a variable, which is a value. To lift a value to the type level you use the typeof we saw earlier. Because key is a const, typeof key is the "name" literal type, and User[typeof key] becomes string.
T[number]: An Array's Element Type
Array indexes are numbers. So you can ask an array type, "what do I get if I index you with number?" The answer is the array's element type. This small idea is the most common way to derive a type from an array that already exists at runtime:
const roles = ["admin", "editor", "viewer"] as const;
type Role = (typeof roles)[number];
// "admin" | "editor" | "viewer"
function canEdit(role: Role) {
return role === "admin" || role === "editor";
}
canEdit("editor");
// Error: Argument of type '"guest"' is not assignable to parameter of type '"admin" | "editor" | "viewer"'.
canEdit("guest");
There are two steps here. typeof roles lifts the value to the type level and gives the tuple readonly ["admin", "editor", "viewer"]. [number] then gives the element type at any index of that tuple, which is the union of the three literals. The list of roles now lives in one place: add a new role to the array and the Role type widens with it. typeof roles[number] without parentheses works the same way, but the parenthesized form tells the reader plainly what happens first.
The same approach works for arrays of objects. If you have sample data, you can pull its type out without writing it separately:
const products = [
{ id: 1, title: "Mechanical Keyboard", price: 120 },
{ id: 2, title: "USB-C Hub", price: 45 },
];
type Product = (typeof products)[number];
// { id: number; title: string; price: number; }
function formatPrice(product: Product) {
return `${product.title}: $${product.price}`;
}
console.log(formatPrice(products[1])); // USB-C Hub: $45
With tuples you can ask for a specific position. And since a tuple's length is a property too, even ["length"] gives you a type:
type Entry = [key: string, value: number];
type First = Entry[0]; // string
type Size = Entry["length"]; // 2
// Error: Tuple type 'Entry' of length '2' has no element at index '2'.
type Third = Entry[2];
Together with Generics: T[K]
Indexed access really shows its strength alongside generics. We've already seen the K extends keyof T constraint. Now let's combine it with T[K] and fix the loose updateSettingLoose function from above:
type Settings = {
theme: "light" | "dark";
fontSize: number;
autoSave: boolean;
};
const settings: Settings = { theme: "light", fontSize: 14, autoSave: true };
function updateSetting<K extends keyof Settings>(key: K, value: Settings[K]) {
settings[key] = value;
}
function getSetting<K extends keyof Settings>(key: K): Settings[K] {
return settings[key];
}
updateSetting("fontSize", 16);
updateSetting("theme", "dark");
// Error: Argument of type '14' is not assignable to parameter of type '"dark" | "light"'.
updateSetting("theme", 14);
const size = getSetting("fontSize"); // number
console.log(size); // 16
Here's the difference: in the updateSetting("theme", ...) call, TypeScript infers K as the "theme" literal type. That makes the type of the value parameter Settings["theme"], which is "light" | "dark". The key and the value are now tied together. By the same logic, getSetting("fontSize") returns number; the return type changes with the key you pass on each call.
Note the order in the error message: TypeScript 7 prints unions in a stable order, which is why you see "dark" | "light" in the message. The type is the same; only the display order differs.
Pulling Pieces Out of Large Types
A situation you'll run into often in real projects: an API's response type arrives as one big piece, often generated automatically by a tool, and its inner parts have no names of their own. With indexed access you can name those parts:
type OrdersResponse = {
data: {
orders: {
id: string;
total: number;
items: { sku: string; quantity: number }[];
}[];
};
meta: { page: number; pageSize: number };
};
type Order = OrdersResponse["data"]["orders"][number];
type OrderItem = Order["items"][number];
type Pagination = OrdersResponse["meta"];
function countItems(order: Order): number {
return order.items.reduce((sum: number, item: OrderItem) => sum + item.quantity, 0);
}
function nextPage(meta: Pagination): number {
return meta.page + 1;
}
The direction matters here: there is one source type, and the others are derived from it. When the response type changes, Order and OrderItem change too, and any code that no longer fits fails to compile. One word of caution: very long chains produce types that are hard to read. If a piece is used in many places in the project, give it a short name as in this example instead of writing the chain from scratch every time.
Common Pitfalls
type Profile = {
name: string;
nickname?: string;
};
type Nickname = Profile["nickname"]; // string | undefined
const statuses = ["active", "banned"];
type Status = (typeof statuses)[number]; // string
type Scores = Record<string, number>;
type Score = Scores["anyTypo"]; // number, no complaint
The first pitfall is optional properties. Because of nickname?, Profile["nickname"] isn't just string; it's string | undefined. When you give that type to a parameter, you'll also have to deal with the undefined case inside the function.
The second is forgetting as const. Without as const, the statuses array is inferred as string[], so [number] gives you plain string instead of a union of literals. The compiler says nothing; you just don't get the narrow type you expected.
The third is index signatures. A type like Record<string, number> accepts any string key, so Scores["anyTypo"] raises no error and returns number. TypeScript can only catch misspelled keys in types whose keys are known one by one.
And a reminder: indexed access is purely a type-level operation. Nothing like Order["status"] remains in the compiled JavaScript, and it performs no check at runtime.
A Small Experiment
★☆☆ Predict, then try it in the Playground: given type Config = { db: { host: string; port: number }; debug: boolean };, which types do Config["db"]["port"] and Config["db" | "debug"] resolve to?
★★☆ Does it compile? const sizes = ["s", "m", "l"]; type Size = (typeof sizes)[number]; const pick: Size = "xl"; If it does, what small change would turn that line into an error?
★★★ Write a generic pluck function that collects the values of a single property from an array of objects: the return type of pluck(products, "title") should be string[], and that of pluck(products, "price") should be number[]. Hint: the return type is T[K][].
In this post we pulled the pieces of a type out one at a time. In the next one we move on to the utility types TypeScript ships ready-made for these kinds of transformations: Partial, Pick, Omit, Record and friends. When you look at their definitions, you'll find this post's T[K] in there too.