keyof & Indexed Access
keyof gives you the keys as a type
Section titled “keyof gives you the keys as a type”The keyof operator takes an object type and produces a union of its keys as a type.
interface User { id: number; name: string; active: boolean;}
type UserKey = keyof User;// ^? "id" | "name" | "active"keyof User is not a value — it is the union type "id" | "name" | "active". This is the raw material for iterating over a type, which is exactly what mapped types do.
For an object type with an index signature, keyof gives the index type:
type Dict = { [key: string]: number };type DictKey = keyof Dict;// ^? string | number (JS object keys can be accessed by number too)Indexed access: T[K] looks up value types
Section titled “Indexed access: T[K] looks up value types”Once you have keys, indexed access looks up the type of the value at a key — the type-level version of obj[key].
interface User { id: number; name: string;}
type IdType = User["id"];// ^? number
type NameType = User["name"];// ^? stringYou can index with a union of keys to get a union of value types:
interface User { id: number; name: string;}
type Values = User[keyof User];// ^? number | stringUser[keyof User] reads as “the type of every value in User” — a common idiom for getting the union of a type’s value types.
flowchart LR
t["User { id: number, name: string }"] -->|keyof| keys["keys: id or name"]
keys -->|"T[key]"| vals["value types: number or string"] Indexing arrays and tuples
Section titled “Indexing arrays and tuples”Arrays and tuples are object types too, so indexed access works on them — and [number] is the trick for “the element type”:
type Nums = number[];type Elem = Nums[number];// ^? number
const tuple = [1, "two", true] as const;type TupleElem = (typeof tuple)[number];// ^? 1 | "two" | true
type First = (typeof tuple)[0];// ^? 1Arr[number] means “index this array with any number,” which yields the union of all element types — the standard way to turn an array/tuple type into a union.
Why these two matter so much
Section titled “Why these two matter so much”Nearly every utility type is built from keyof plus indexed access plus a mapped type:
interface User { id: number; name: string;}
// Pick, from scratch — iterate chosen keys, look up each value typetype MyPick<T, K extends keyof T> = { [P in K]: T[P];};
type NameOnly = MyPick<User, "name">;// ^? { name: string }K extends keyof T constrains the keys to real keys of T; [P in K] iterates them (a mapped type, next lesson if you skipped it); T[P] looks up each value type. Three primitives, one useful tool.
keyof any is string | number | symbol
Section titled “keyof any is string | number | symbol”keyof is not limited to a specific object type — you can ask what keyof any is, and the answer is the full set of things JavaScript allows as a property key:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type KA = keyof any;// ^? string | number | symbol
type _kaCheck = Expect<Equal<KA, string | number | symbol>>;This is not a coincidence — a JavaScript property key really can be a string, a number (which gets coerced to a string at runtime, but TypeScript tracks it separately for tuple/array indexing), or a symbol. keyof any shows up constantly as a constraint on a generic key parameter: Record<K extends keyof any, V> — the real lib definition of Record — says “K can be anything a JavaScript object could actually use as a key,” which is broader than keyof of any specific object type.
keyof on a generic type parameter stays deferred
Section titled “keyof on a generic type parameter stays deferred”Inside a generic function, keyof T cannot be resolved to a concrete union of literal keys — T is not yet a specific type, so the compiler keeps keyof T as an opaque, deferred expression until the function is called with a real type argument. This has a very practical consequence: Object.keys cannot be typed to return (keyof T)[], even though that looks like exactly what it should return.
// @expect-error TS2322function describe<T extends object>(t: T) { const keys: (keyof T)[] = Object.keys(t); return keys;}error TS2322: Type 'string[]' is not assignable to type '(keyof T)[]'. Type 'string' is not assignable to type 'keyof T'. Type 'string' is not assignable to type 'never'.Two things are happening here. First, Object.keys is typed to return string[], full stop — the real lib signature does not even attempt (keyof T)[], for a soundness reason: at runtime, an object typed as T can carry more keys than T declares (structural typing allows extra properties on the concrete value, and a subclass or an object literal assigned through a wider type can carry keys T never mentions), so a caller cannot assume the runtime key list matches keyof T exactly. Second, notice the error trail: TypeScript reports keyof T as not assignable from string, then reports the deferred keyof T as if it were never in this context, precisely because it has no concrete type for T yet to resolve keyof T against. This is why every real-world Object.keys(obj).forEach(k => ...) pattern that indexes back into obj with k needs a cast or a runtime-validated key list — the type system is not being obtuse, it is correctly refusing to promise something it cannot verify.
Indexed access on a union distributes
Section titled “Indexed access on a union distributes”When you index into a union of object types with a key, indexed access checks each member of the union separately and unions the results back together — the same distributive instinct conditional types have, applied to a lookup instead of a branch:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type Data = { type: "a"; value: string } | { type: "b"; value: number };
type ValueType = Data["value"];// ^? string | number
type _valueDistributes = Expect<Equal<ValueType, string | number>>;Data["value"] reads each union member’s value property separately — string from the first, number from the second — and unions them into string | number. This is exactly what lets a discriminated union’s shared property be indexed safely without narrowing first, and it is the same mechanism Getters<T> and every mapped-type-over-a-union pattern from the next lesson relies on.
keyof any, deferred generic keyof, and indexed access distributing over a union
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
// keyof any: the full set of legal JS property key typestype KA = keyof any;type _kaCheck = Expect<Equal<KA, string | number | symbol>>;
// keyof T stays deferred inside a generic -- Object.keys is always string[]function describe<T extends object>(t: T) { const keys = Object.keys(t); type K = typeof keys; type _isStringArray = Expect<Equal<K, string[]>>; return keys;}
// indexed access on a union distributes over each membertype Data = { type: "a"; value: string } | { type: "b"; value: number };type ValueType = Data["value"];type _valueDistributes = Expect<Equal<ValueType, string | number>>; Mental Model
Section titled “Mental Model”flowchart TD concrete["T is a concrete type, e.g. User"] --> resolved["keyof T resolves immediately to a literal union"] generic["T is a bare generic type parameter"] --> deferred["keyof T stays an opaque, deferred expression"] deferred --> callsite["Only resolves once the function is called with a real type argument"]
Treat keyof T inside a generic the way you would treat a variable captured in an unevaluated closure: the expression is legitimate and well-typed, but there is nothing concrete to compute yet. The compiler defers it rather than guessing, which is exactly why Object.keys(t) — a real, eager runtime computation — cannot be typed against something that has not been resolved yet.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Object.keys typed as string[] | Sound — never promises a key that is not actually a string at runtime, even when the object carries extra properties beyond T | You lose the connection to keyof T entirely and must cast or validate to get typed keys back |
Casting Object.keys(t) as (keyof T)[] | Gets you a typed iteration for cases where you control every value assigned as T | Reintroduces exactly the unsoundness Object.keys’s real signature was protecting against — an object with extra keys silently breaks the promise |
Indexed access on a union (Data["value"]) | Reads naturally, and matches how a discriminated union’s shared property behaves without narrowing | Only works for a key present on every union member — indexing a key present on only some members is a compile error, not a partial result |
Common Misconceptions
Section titled “Common Misconceptions”keyof Tinside a generic function can be read as the actual keys of whateverTturns out to be, right there in the function body. It cannot —keyof Tis deferred until instantiation. Inside the generic body it behaves as an opaque type, not a literal union you can pattern-match on.Object.keysreturningstring[]instead of(keyof T)[]is a limitation the TypeScript team just has not gotten around to fixing. It is a deliberate soundness choice: a value’s runtime type can carry more keys than its static typeTdeclares, so(keyof T)[]would be a promise the compiler cannot keep.- Indexing a union with a key gives you the value type from only one branch, whichever “wins.” It distributes over every member that has the key and unions all of their value types together — there is no winner, only a union.
Reading AI-generated code
Section titled “Reading AI-generated code”- Generated code that assumes
Object.keys(obj)can be indexed straight back intoobj(Object.keys(obj).forEach(k => console.log(obj[k]))) usually needs either a cast, akeyof typeof objnarrowing at the call site, or acceptance that the values read this way are typed as the union of all property types — not a targeted lookup. Silently typing the loop variablekasanyto make the error disappear throws away the exact protectionObject.keys’s real signature exists to provide. - A generic helper that casts
Object.keys(t) as (keyof T)[]without comment is making a promise aboutThaving no extra runtime keys — worth checking whether that promise is actually true at every call site, especially ones passing in a subtype or an object literal assigned through a wider variable. Data["someKey"]used on a union wheresomeKeyexists on only some members is a compile error, not a partial orundefined-flavored result — if generated code appears to expect the latter, the union’s shape (or the code’s assumption about it) needs a second look.
🐛 Spot the bug
interface Config {host: string;port: number;debug: boolean;}
function logAllSettings<T extends object>(config: T) {const keys: (keyof T)[] = Object.keys(config);for (const key of keys) { console.log(key, config[key]);}}Show the answer
This does not compile — Object.keys(config) is string[], and string[] is not assignable to (keyof T)[] (the exact TS2322 from the deferred-keyof section above). It is tempting to “fix” this with Object.keys(config) as (keyof T)[], but that cast is exactly the unsoundness the real signature avoids: if logAllSettings is called with a Config value that was actually constructed as { ...defaultConfig, extra: "field" } and passed through a wider type, keys would silently include "extra", which the annotation (keyof T)[] promised could never happen. The fix that keeps the guarantee is to not fight the real type — iterate Object.keys(config) as string[] and, if you need to read back into config, either validate each key against a known list of T’s keys at runtime, or accept a narrower, explicit key parameter (function logSettings<T, K extends keyof T>(config: T, keys: K[])) instead of trying to recover keyof T from a runtime scan.