- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Object.keys(user) as (keyof User)[] sits in a lot of TypeScript codebases, usually in a utils file next to a comment that blames the compiler.
The TypeScript team has heard that complaint for about a decade. In May 2020, Ryan Cavanaugh of the TypeScript team wrote in a related issue that they "basically get a bug report or PR to change Object.keys to keyof T at least once a week" (microsoft/TypeScript#38520). The answer keeps being no, and it has been no since Anders Hejlsberg's objection on a 2016 pull request that tried to make the change (#12253).
The no is deliberate. Once you see the reason, the cast is where the bug lives.
The cast that type-checks and then throws
The declaration in TypeScript's own lib file is one line: keys(o: object): string[]; (lib.es5.d.ts). Here is the program everyone writes to get around it:
interface User { id: number; name: string; } const labels: Record<keyof User, string> = { id: "ID", name: "Full name", }; const row = { id: 1, name: "Ada", isAdmin: true }; const user: User = row; // compiles: row is not a fresh literal const keys = Object.keys(user) as (keyof User)[]; for (const k of keys) { console.log(labels[k].toUpperCase()); } This compiles under strict. Every line is legal. row has the type { id: number; name: string; isAdmin: boolean }, which is assignable to User because it has everything User asks for. The cast tells the compiler that keys holds only "id" and "name", so labels[k] is typed string and .toUpperCase() is allowed.
At runtime, user and row are the same object. Object.keys returns the object's own enumerable string-keyed property names, all of them, so it returns ["id", "name", "isAdmin"]. The loop prints two labels, then reaches isAdmin, reads labels["isAdmin"], gets undefined, and calls .toUpperCase() on it. That is a TypeError, in code the compiler signed off on because the as told it to.

A type lists what a value must have. The value can carry more.
Structural typing: User is a floor, not a ceiling
TypeScript checks shapes. A value is a User if it has an id: number and a name: string. Nothing in that rule says it can't have more. That is what makes structural typing pleasant day to day: you can pass a database row, a class instance or an API payload anywhere a narrower interface is expected, as long as the required fields are there.
The same rule turns (keyof User)[] into a false promise. Hejlsberg put it this way on the 2016 pull request: "an object can (and often does) have more properties at run-time than are statically known at compile time" (#12253). His example in the same thread was a Base type: calling Object.keys on a Base and getting (keyof Base)[] back "is almost certainly wrong because an actual instance would be a derived object with more keys".
You don't need inheritance to hit it. All of these hand a wider object to code that sees User:
- a function
render(u: User)called with arowvariable that also carriesrole -
JSON.parse(body) as Useron a response that grew a field last sprint - an ORM row with
createdAtandupdatedAtthat you typed asUser -
const withSession = { ...user, sessionId }, later passed to aUserparameter
In every case keyof User is a lower bound on the runtime keys. Object.keys is the runtime telling you the truth, and string[] is the type that matches it.
What keyof actually computes
keyof is a type-level operator with its own rules, and several of them diverge from what Object.keys can return:
type K1 = keyof User; // "id" | "name" type K2 = keyof { [k: string]: boolean }; // string | number type K3 = keyof { [n: number]: unknown }; // number type K4 = keyof { 0: string }; // 0 type K5 = keyof {}; // never K2 surprises people. The TypeScript handbook explains that with a string index signature, keyof gives string | number "because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"]".
K4 goes the other way. Since TypeScript 2.9, keyof T is a subtype of string | number | symbol, and a number-like property name stays a number literal. Object.keys({ 0: "a" }) returns ["0"], a string. So even on an object with no extra properties, (keyof T)[] would put the number 0 in the type where the runtime gives you the string "0". Symbol keys show up in keyof and never in Object.keys, which returns only string-keyed properties (MDN).
K5 is the case Hejlsberg called degenerate: {} accepts almost any value, yet keyof {} is never, so a keyof-typed Object.keys would claim every such object has no keys at all (#12253).
Excess property checks only guard fresh literals
"But TypeScript does reject extra properties," you might say. It does, in one place. The handbook says "object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments" (Object Types). The same page shows the escape hatch: assign the object to another variable first, and the check no longer runs.
// Error: 'isAdmin' does not exist in type 'User' const a: User = { id: 1, name: "Ada", isAdmin: true }; // Fine: same value, routed through a variable const row = { id: 1, name: "Ada", isAdmin: true }; const b: User = row; The check is a lint for typos at the moment you write a literal, and it can't follow a value across a function boundary. A real guarantee would need exact object types, which have been an open proposal since December 15, 2016 (#12936), still labelled "Awaiting More Feedback".
Until something like that ships, the language has no way to say "this User has exactly these keys", so Object.keys can't say it either.

Typed keys are safe when you control what goes into the container.
Four patterns that stay honest
The common thread: the key type comes from something you built.
1. Own the key list. Write the keys once as a tuple and iterate that, never the object.
const USER_FIELDS = ["id", "name"] as const satisfies readonly (keyof User)[]; type UserField = (typeof USER_FIELDS)[number]; function describe(u: User): string[] { return USER_FIELDS.map((k) => `${k}=${u[k]}`); } Extra properties on u are ignored because you never looked for them. The satisfies clause catches a typo in the list. It does not prove you listed every key; pattern 2 shows how to get that guarantee.
2. satisfies on a literal you wrote. The satisfies operator from TypeScript 4.9 validates "that the type of an expression matches some type, without changing the resulting type of that expression". On an object literal it runs the excess property check, and Record<keyof User, string> makes a missing key an error too.
const userLabels = { id: "ID", name: "Full name", } satisfies Record<keyof User, string>; // Only for objects you created as literals. const literalKeys = <T extends object>(o: T) => Object.keys(o) as (keyof T & string)[]; for (const k of literalKeys(userLabels)) { console.log(k, userLabels[k].toUpperCase()); } This is still a cast. It is safe here only because the object is the literal you wrote three lines up and nothing adds keys to it. The & string drops the number and symbol members keyof can include. Pass a value typed User into literalKeys and you are back to the opening bug, which is why the helper's name says what it is for.
3. Use a Map and let it enforce the key type. MDN puts the difference plainly: "A Map does not contain any keys by default. It only contains what is explicitly put into it."
const labelMap = new Map<keyof User, string>([ ["id", "ID"], ["name", "Full name"], ]); for (const [k, label] of labelMap) { console.log(k, label.toUpperCase()); } The constructor and set only accept keyof User keys, so the key type holds without a cast. labelMap.get(k) returns string | undefined, which is the correct type for a lookup.
4. Narrow strings from outside. For a parsed body or a query string, keep Object.keys as string[] and check each key against the list you own:
function isUserField(k: string): k is UserField { return (USER_FIELDS as readonly string[]).includes(k); } function logUserFields(input: Record<string, unknown>) { for (const k of Object.keys(input)) { if (isUserField(k)) console.log(k, input[k]); } } Unknown keys fall through instead of getting a type they never had.
Where to look first
Object.keys answers "what keys does this value have right now", and no static type can answer that for a structurally typed value. Grep your codebase for as (keyof and check where each value came from. If your code wrote it as a literal and nothing widened it since, the cast is fine. If it arrived through a parameter or a parsed body, it is the opening bug waiting for a wider object. The next time someone files the weekly Object.keys issue, the answer is already in #12253.
If this was useful
The trick in this post, telling the compiler's key type apart from the runtime's keys, sits at the heart of The TypeScript Type System. It goes deep on generics, mapped and conditional types and branded types, so you can write helpers like literalKeys and know exactly where they stop being true.
It is one of five books in The TypeScript Library. Books 1 and 2 are the core path; if you are coming from the JVM or PHP, book 3 or 4 covers that same ground from where you stand; book 5 is for anyone shipping TypeScript at work:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.
