Ch. 2 · TypeScript

TypeScript Keyof and Safe Property Access

Why indexing an object with a plain string fails (TS7053), how keyof typeof and T[K] fix it, and why Object.keys still returns string[].

~5 min readintermediateupdated Oct 7, 2026

Reading a property by a computed name is ordinary JavaScript: config[name], translations[locale], handlers[event.type]. In TypeScript it’s also where a lot of people first meet error TS7053 and reach for as any. The two operators that fix it properly, keyof and indexed access types, are small, and they show up in almost every generic utility you’ll be asked to write in an interview.

The TS7053 error and what it’s protecting

Here’s a feature-flag lookup:

const FLAGS = { darkMode: false, betaSearch: true, newCheckout: false };

function isEnabled(name: string): boolean {
  return FLAGS[name];
}
TypeScript
key1.ts(4,10): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ darkMode: boolean; betaSearch: boolean; newCheckout: boolean; }'.
  No index signature with a parameter of type 'string' was found on type '{ darkMode: boolean; betaSearch: boolean; newCheckout: boolean; }'.
Text

FLAGS has exactly three known keys. name could be any string, including "darkmode" with a lowercase m, or "toString". TypeScript doesn’t know what FLAGS["anything"] is, so the result would be an implicit any, and noImplicitAny (part of strict) rejects that. The error isn’t about the lookup being impossible; it’s about the result type being unknown.

keyof typeof: a union of the real keys

typeof FLAGS in a type position gives you the object’s type. keyof turns that type into a union of its keys:

type FlagName = keyof typeof FLAGS; // "darkMode" | "betaSearch" | "newCheckout"

function isEnabledOk(name: FlagName): boolean {
  return FLAGS[name];
}

isEnabledOk("darkmode");
TypeScript
key1.ts(11,13): error TS2345: Argument of type '"darkmode"' is not assignable to parameter of type '"betaSearch" | "darkMode" | "newCheckout"'.
Text

Now the lookup type-checks, because every possible name is a real key with a boolean value. The typo moves from a runtime undefined to a compile error at the call site, and editors autocomplete the three names. The key list also stays in sync automatically: add a flag to the object and the type grows with it. (Recent compilers print union members in a stable sorted order, which is why the message lists "betaSearch" first.)

One detail about keyof on index signatures catches people out in multiple-choice questions:

type Dict = { [key: string]: number };
type DK = keyof Dict; // string | number
TypeScript

It’s string | number, not string, because JavaScript converts numeric property keys to strings, so dict[0] and dict["0"] are the same lookup and TypeScript allows both.

Indexed access types: T[K]

Square brackets work in type positions too. T[K] means “the type of property K on T”:

type Order = { id: string; total: number; lines: { sku: string; qty: number }[] };

type Total = Order["total"];            // number
type Line = Order["lines"][number];     // { sku: string; qty: number }
type IdOrTotal = Order["id" | "total"]; // string | number
type Bad = Order["customer"];
TypeScript
key1.ts(25,18): error TS2339: Property 'customer' does not exist on type 'Order'.
Text

Three patterns are worth knowing. A single key gives that property’s type. A union of keys gives the union of their types. And indexing an array type with number gives its element type, which is the shortest way to name “one line of an order” without declaring a separate interface. A key that doesn’t exist is an error, so these derived types break loudly when the source changes, which is what you want.

Note that the index must be a type. With const key = "total", writing Order[key] fails because key is a value, and the compiler says so directly: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'? Writing Order[typeof key] works, because typeof key is the literal type "total". The same confusion between the value world and the type world explains why keyof FLAGS fails and keyof typeof FLAGS works.

Combined with generics, this pair gives you the signature behind every typed getter:

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
TypeScript

The return type follows the key: get(order, "total") is number, and get(order, "lines") is the line array.

Why Object.keys returns string[]

Sooner or later you’ll loop over an object and hit TS7053 again:

for (const key of Object.keys(FLAGS)) {
  console.log(FLAGS[key]); // TS7053: 'key' is string
}
TypeScript

Plenty of people consider this a TypeScript bug. It’s deliberate, and explaining why is a favourite interview follow-up. TypeScript’s types describe the minimum shape of an object, not its exact contents. A value typed { darkMode: boolean } can legally be an object with more properties:

type Flags = { darkMode: boolean };
function listFlags(flags: Flags) {
  return Object.keys(flags);
}

const fromServer = { darkMode: true, legacyHeader: "yes" };
console.log(listFlags(fromServer)); // [ 'darkMode', 'legacyHeader' ]
TypeScript

That compiles and runs. If Object.keys returned (keyof Flags)[], the type would claim the array contains only "darkMode", and code indexing a lookup table with those keys would fail at runtime on "legacyHeader". So string[] is the honest type.

When you control the object and know it has no extras, like a const literal in the same module, a local cast is reasonable: (Object.keys(FLAGS) as FlagName[]). Keep it next to the literal so the assumption is visible. Object.entries has the same reasoning and the same workaround.

Accepting keys from outside

Flags often arrive as strings from a query parameter or an admin panel. You need to turn an arbitrary string into a FlagName, and the check must happen at runtime:

function isFlagName(name: string): name is FlagName {
  return Object.hasOwn(FLAGS, name);
}

function isEnabled(name: string): boolean {
  return isFlagName(name) ? FLAGS[name] : false;
}

console.log(isEnabled("betaSearch"), isEnabled("toString"), "toString" in FLAGS);
// true false true
TypeScript

That output is from running the compiled code. Object.hasOwn checks only the object’s own properties. The in operator walks the prototype chain, so "toString" in FLAGS is true, and a guard written with in would accept "toString" and return a function where the type promised a boolean. When the keys come from users, that difference matters.

One more setting is worth knowing. With a Record<string, number>, prices["banana"] is typed number even though the key may not exist. Turning on noUncheckedIndexedAccess makes such lookups number | undefined; on the same code it reports Type 'number | undefined' is not assignable to type 'number'. It isn’t part of strict, but it’s a good choice for codebases that index records with external keys.

Interview practice

You have const messages = { en: { greeting: "Hello" }, fr: { greeting: "Bonjour" } }. Write a function t(locale, key) where locale must be a known locale, key must be a key of that locale’s messages, and the return type is string. Then say how you’d handle a locale string from the browser.

Answer

type Locale = keyof typeof messages;
type MessageKey = keyof (typeof messages)["en"];

function t(locale: Locale, key: MessageKey): string {
  return messages[locale][key];
}
TypeScript

Locale is "en" | "fr", from keyof typeof. MessageKey uses an indexed access type to reach the English bundle and then takes its keys, treating English as the source of truth. If French is missing a key, messages[locale][key] stops compiling, because fr would no longer have every MessageKey, which catches incomplete translations at build time. A stricter version makes that explicit by typing messages with satisfies Record<string, Record<MessageKey, string>>.

For navigator.language, which is an arbitrary string like "fr-CA", I’d normalise it to the base language and pass it through a guard such as isLocale(s: string): s is Locale that uses Object.hasOwn(messages, s), falling back to "en". I wouldn’t cast the browser string to Locale, because then an unsupported language would reach messages[locale] and read undefined.

Further reading

More in TypeScript

esc