for in JavaScript: Loops and Types
for...in walks the keys of an object. It looks like the simplest construct in the
language, and for about ten lines it is. Then you use the key to index the object it came
from, and TypeScript hands you an error you did not ask for.
The short version: for in JavaScript gives you the string keys of an object, inherited
ones included, and TypeScript types that key as string rather than keyof T. Both halves
of that surprise people. This page covers what the loop actually iterates, why the key
cannot be narrower, and the three ways out.
What for in JavaScript actually iterates
Point it at an object literal and you get what you expect:
const settings = { theme: 'dark', fontSize: 14, compact: true }
for (const settingKey in settings) {
console.log(settingKey)
}
// 'theme'
// 'fontSize'
// 'compact'Three rules decide the set of keys. They are enumerable, they are strings, and they are not limited to the object's own properties. That last one is the rule people forget:
const baseTheme = { spacing: 8 }
const userTheme = Object.create(baseTheme) as { spacing: number; accent: string }
userTheme.accent = 'teal'
for (const themeKey in userTheme) {
console.log(themeKey)
}
// 'accent'
// 'spacing' ← inherited from the prototypeSymbol keys are skipped entirely, and so is anything defined with enumerable: false:
const tagged = { visible: 1, [Symbol('hidden')]: 2 }
for (const taggedKey in tagged) {
console.log(taggedKey)
}
// 'visible' — the symbol key never shows upThe key is always a string
Here is the line that sends people looking for an answer:
const dbConfig = { host: 'localhost', port: 5432 }
for (const dbKey in dbConfig) {
console.log(dbKey) // dbKey: string — not 'host' | 'port'
// dbConfig[dbKey] ❌ TS7053: Element implicitly has an 'any' type because expression of
// type 'string' can't be used to index type '{ host: string; port: number; }'
}The loop variable is typed string and nothing else. Object.keys has the same signature,
so this is not a quirk of the loop — it is a deliberate decision about what TypeScript is
willing to promise.
Why it cannot be keyof T
Object types in TypeScript are not sealed. A value is allowed to carry properties its type does not mention, which is the whole basis of structural typing:
type Point = { x: number; y: number }
const labelled = { x: 1, y: 2, label: 'origin' }
const point: Point = labelled // ✅ allowed — labelled has everything Point needs
for (const pointKey in point) {
console.log(pointKey)
}
// 'x', 'y', 'label'That loop prints label, and label is not keyof Point. If TypeScript typed the key as
keyof Point, it would have just lied to you about a value the type system itself allowed
through. string is the honest answer.
Three ways to get typed keys
Cast with keyof typeof
The cheapest fix, and the right one when the object literal is written in the same scope:
const palette = { primary: '#0af', danger: '#f30' }
for (const paletteKey in palette) {
const swatch = palette[paletteKey as keyof typeof palette]
console.log(swatch.toUpperCase())
}You can see the literal, so you know nothing else is in there. The moment the object arrives from a function parameter or a JSON payload, that guarantee is gone and the cast becomes a guess. Use it where the object is born, not where it is passed around.
Use a typed helper and for...of
If you need this in more than one place, wrap the assertion once:
function typedKeys<T extends object>(source: T): (keyof T)[] {
return Object.keys(source) as (keyof T)[]
}
const scores = { alice: 90, bob: 72 }
for (const scoreKey of typedKeys(scores)) {
console.log(scoreKey, scores[scoreKey].toFixed(0))
}Note that this switched to for...of. That is the point — you get own properties only, no
prototype walk, and the key keeps its union. When you want the value as well,
Object.entries does the same job in one call and has the same
widening problem with the same fix.
Make it a Record
Often the object was never a fixed shape to begin with. It is a dictionary, and saying so makes the whole problem disappear:
type ExchangeRates = Record<string, number>
const rates: ExchangeRates = { eur: 1.08, gbp: 1.27 }
for (const rateKey in rates) {
console.log(rateKey, rates[rateKey].toFixed(2)) // ✅ no cast needed
}An index signature accepts a string key by definition, so indexing works untouched. This
is the cleanest of the three when it applies — see Record for
how to pin the key side down when you do know the keys. One caveat: with
noUncheckedIndexedAccess enabled, rates[rateKey] comes back as number | undefined and
you will need a check before calling toFixed.
Arrays: reach for for...of instead
for...in works on arrays, and it almost never does what you want:
const fruits = ['apple', 'banana']
for (const fruitIndex in fruits) {
console.log(typeof fruitIndex) // 'string' — every single time
}The index arrives as '0' and '1', strings rather than numbers, so arithmetic on it is a
type error. Any non-index property somebody hung on the array gets enumerated too, and the
prototype chain is still in play. Use for...of with entries() when you want both halves:
const medals = ['gold', 'silver', 'bronze']
for (const [medalIndex, medal] of medals.entries()) {
console.log(medalIndex + 1, medal) // medalIndex is a real number
}That runs on the iterator protocol rather than key enumeration, which is why it only sees elements.
Filtering out inherited keys
When you do stay on for...in, guard the body. Object.hasOwn is the modern form:
const inventoryBase = { currency: 'USD' }
const inventory: Record<string, unknown> = Object.create(inventoryBase)
inventory.widgets = 12
for (const itemKey in inventory) {
if (!Object.hasOwn(inventory, itemKey)) continue
console.log(itemKey, inventory[itemKey])
}
// 'widgets' onlyObject.hasOwn landed in ES2022. On an older lib setting, use
Object.prototype.hasOwnProperty.call(inventory, itemKey) — calling it through call rather
than as a method, because an object built from Object.create(null) does not have the
method at all.
The order is not insertion order
One more thing worth knowing before you rely on the sequence:
const mixedKeys = { b: 1, 2: 'two', a: 3, 1: 'one' }
for (const mixedKey in mixedKeys) {
console.log(mixedKey)
}
// '1', '2', 'b', 'a'Integer-like keys come first in ascending numeric order, then the remaining string keys in insertion order. This is specified behaviour, not an engine detail, but it catches people who assume their keys come back the way they wrote them.
The other in keywords
in shows up in two more places, and conflating them is a common source of confusion.
In a mapped type it iterates a union of keys at the type level. No runtime loop is involved:
type FeatureFlags = { darkMode: boolean; beta: boolean }
type FlagOwners = { [FlagKey in keyof FeatureFlags]: string }
// { darkMode: string; beta: string }keyof FeatureFlags here is 'darkMode' | 'beta' — a union of
string literals, which is exactly the type for...in refuses to give you at runtime.
As a binary operator, in tests for a property and narrows the value:
type Circle = { radius: number }
type Square = { side: number }
function areaOf(shape: Circle | Square): number {
return 'radius' in shape ? Math.PI * shape.radius ** 2 : shape.side ** 2
}Same keyword, three jobs. Only the first one is a loop.
Changing the object while you loop over it
Mutating an object mid-iteration is one of those things that looks fine in a test and bites you in production. The spec is only half-committal about it, so the two directions behave differently.
Deleting is well defined. A key removed before the loop reaches it is never visited:
const queue = { first: 1, second: 2, third: 3 }
for (const queueKey in queue) {
if (queueKey === 'first') {
delete (queue as Partial<typeof queue>).second
}
console.log(queueKey)
}
// 'first', 'third' — 'second' was gone by the time the loop got thereThe Partial cast is there because delete only accepts optional properties, which is
TypeScript pointing at the same hazard from the type side.
Adding is where it gets vague. A key added during iteration may or may not be visited — the spec leaves it to the engine:
const registry: Record<string, number> = { alpha: 1 }
for (const registryKey in registry) {
if (registryKey === 'alpha') {
registry.omega = 2
}
console.log(registryKey)
}
// 'alpha' in V8 — but do not write code that depends on either answerIf the body of your loop writes back to the object it is reading, snapshot the keys first and iterate the snapshot. It costs one array and removes the whole class of problem:
for (const snapshotKey of Object.keys(registry)) {
console.log(snapshotKey)
}for...in vs Object.keys vs Reflect.ownKeys
The three ways to enumerate properties disagree on exactly three things: inherited keys, symbol keys, and non-enumerable keys. An object with one of each makes the split obvious:
const hiddenBox: Record<string, number> = Object.create({ inherited: 1 })
hiddenBox.plain = 2
Object.defineProperty(hiddenBox, 'secret', { value: 3, enumerable: false })
console.log(Object.keys(hiddenBox)) // ['plain']
console.log(Reflect.ownKeys(hiddenBox)) // ['plain', 'secret']
for (const boxKey in hiddenBox) {
console.log(boxKey) // 'plain', then 'inherited'
}| Own | Inherited | Non-enumerable | Symbols | |
|---|---|---|---|---|
for...in | ✅ | ✅ | ❌ | ❌ |
Object.keys | ✅ | ❌ | ❌ | ❌ |
Reflect.ownKeys | ✅ | ❌ | ✅ | ✅ |
Object.keys is the one you want almost every time. Reflect.ownKeys is for tooling —
serialisers, proxies, anything that has to see the whole surface of an object rather than
the part meant for consumers.
A worked example: shallow diff
Putting the advice together, here is a helper that reports which fields changed between two
objects. It is the shape of code where for...in genuinely earns its place:
type ChangedField = { key: string; from: unknown; to: unknown }
function shallowDiff(
before: Record<string, unknown>,
after: Record<string, unknown>,
): ChangedField[] {
const changed: ChangedField[] = []
for (const diffKey in after) {
if (!Object.hasOwn(after, diffKey)) continue
if (before[diffKey] !== after[diffKey]) {
changed.push({ key: diffKey, from: before[diffKey], to: after[diffKey] })
}
}
return changed
}
const previousUser = { name: 'Ada', role: 'admin' }
const nextUser = { name: 'Ada', role: 'owner' }
console.log(shallowDiff(previousUser, nextUser))
// [{ key: 'role', from: 'admin', to: 'owner' }]Three decisions are doing the work. The Record<string, unknown> parameters mean
before[diffKey] type-checks with no cast. The Object.hasOwn guard keeps prototype
properties out. And unknown rather than any on the value forces the caller to narrow
before using it.
The honest caveat: this only reports keys present in after, so a deleted field goes
unnoticed. Loop over before as well if that matters to you.
Summary
for...in enumerates string keys, own and inherited, in a specified order that is not
insertion order:
- The loop variable is typed
string, neverkeyof T, because object types are not sealed and a value can legally carry keys its type does not list. - Cast with
keyof typeofwhere the literal is written, wrapObject.keysin a typed helper when you need it repeatedly, or type the object as aRecordand skip the problem. - Guard with
Object.hasOwnif the object has a prototype you do not control. - On arrays, use
for...of— the index fromfor...inis a string, and non-index properties leak in.
Most of the time the honest answer is that you wanted Object.entries and a for...of
loop. for...in earns its place on genuine dictionaries, and on those a Record type makes
it read cleanly with no casts at all.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges