Object.entries in TypeScript
Object.entries turns an object into an array of key/value pairs. The runtime behaviour takes one
example to explain, and almost nobody searches for it. What people actually hit is the TypeScript
half: you call Object.entries on an object with three known keys, and the keys come back typed as
plain string. The union you wanted is gone.
That is not a bug, and the usual fix — a cast — is right about half the time. This page covers what the type is, why it has to be that way, and the three ways out with their trade-offs.
What Object.entries does
It returns own, enumerable, string-keyed properties as [key, value] pairs, in one array.
const scores = { alice: 90, bob: 72 }
const scorePairs = Object.entries(scores)
// [['alice', 90], ['bob', 72]]
for (const [name, score] of scorePairs) {
console.log(`${name}: ${score}`)
}Three words in that sentence do real work. Own means inherited properties are skipped.
Enumerable means anything defined with enumerable: false is skipped. String-keyed means
symbol keys never appear — Object.entries cannot see them at all.
const idKey = Symbol('id')
const descriptors = Object.create(
{ inherited: true },
{
visible: { value: 1, enumerable: true },
hidden: { value: 2, enumerable: false },
[idKey]: { value: 3, enumerable: true },
},
) as Record<string, number>
console.log(Object.entries(descriptors)) // [['visible', 1]] — the other three are invisibleProperty order is not arbitrary either. Integer-like keys come first in ascending numeric order,
then the remaining string keys in insertion order. That rule is part of the language, so
Object.entries({ b: 1, 2: 2, a: 3 }) reliably gives [['2', 2], ['b', 1], ['a', 3]].
How TypeScript types Object.entries
Here is the declaration from lib.es2017.object.d.ts:
interface ObjectConstructorExample {
entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][]
}Read the return type carefully: [string, T][]. The value type T is inferred from your object,
so that half survives. The key is hard-coded as string. No type parameter, no keyof, nothing to
infer.
const serverConfig = { host: 'localhost', port: 5432, secure: true }
const configEntries = Object.entries(serverConfig)
// [string, string | number | boolean][]Two things happened at once. The keys collapsed to string, and the values collapsed to a union
of every value type in the object. T is a single type parameter, so it has to cover string,
number, and boolean together. Nothing remembers that port is the number one.
That second part bites hardest in a loop:
const settings = { host: 'localhost', port: 5432 }
for (const [key, value] of Object.entries(settings)) {
// key: string
// value: string | number
// value.toFixed(0) ❌ TS2339: Property 'toFixed' does not exist on type 'string | number'
console.log(key, value)
}Why the keys widen to string
This is the part worth understanding, because it decides whether the cast below is safe.
TypeScript object types are not sealed. A value typed { host: string } is allowed to be an
object that has more properties than that at runtime. Only object literals assigned directly get
excess property checking, and that check is easy to step around:
type Config = { host: string }
const rawConfig = { host: 'localhost', debug: true }
const narrowed: Config = rawConfig // ✅ no error — rawConfig has everything Config needs
console.log(Object.entries(narrowed)) // [['host', 'localhost'], ['debug', true]]narrowed is typed Config, so keyof Config is 'host'. But at runtime Object.entries finds
debug as well. If the standard library typed the return as [keyof T, T[keyof T]][], that
declaration would be a lie in this very common case, and the lie would be invisible — you would get
a 'host' in your hands that is really the string 'debug'.
This is the same reason Object.keys returns string[] rather than (keyof T)[]. Both methods
inspect the runtime object, and the runtime object is free to have more on it than its type admits.
So string is the honest answer. The typeof operator page covers how
keyof typeof builds those key unions in the first place.
Fix 1: cast with keyof typeof
When you control the object and know nothing extra can be attached, a cast is fine — and it is the shortest thing that works.
const flags = { darkMode: true, betaBanner: false }
const flagEntries = Object.entries(flags) as [keyof typeof flags, boolean][]
for (const [flagName, enabled] of flagEntries) {
// flagName: 'darkMode' | 'betaBanner' ✅
console.log(flagName, enabled)
}The cast is safe when the object is a const literal that never leaves your module, or comes from
a frozen config. It is not safe when the object arrives as a function parameter, because the
caller can always hand you a wider object:
type Theme = { primary: string }
function listTokens(theme: Theme) {
// ❌ lying: the caller may pass an object with more keys
return Object.entries(theme) as ['primary', string][]
}
const extendedTheme = { primary: '#000', secondary: '#fff' }
listTokens(extendedTheme) // compiles, and the result type is now wrongThe rule of thumb: cast where the literal is written, not where it is consumed.
Numeric keys break the cast
Worth knowing before you reach for keyof typeof reflexively. JavaScript object keys are always
strings, but keyof on a numeric-keyed object gives you number literals, and the compiler stops
you:
const statusText = { 200: 'OK', 404: 'Not Found' }
type StatusKey = keyof typeof statusText // 200 | 404 — numbers, not strings
// Object.entries(statusText) as [StatusKey, string][]
// ❌ TS2352: Conversion of type '[string, string][]' to type '[200 | 404, string][]'
// may be a mistake because neither type sufficiently overlaps with the other.This is one of the rare cases where the unsound cast is caught, because string and 200 | 404
genuinely do not overlap. Reaching for as unknown as here would silence a correct error — the
runtime key really is the string '200'. Convert the key type instead:
const httpStatus = { 200: 'OK', 404: 'Not Found' }
type StatusCode = `${keyof typeof httpStatus}` // '200' | '404' ✅ strings
const statusEntries = Object.entries(httpStatus) as [StatusCode, string][]
for (const [code, text] of statusEntries) {
console.log(Number(code), text) // parse back when you need the number
}Fix 2: a typed entries helper
If you do this more than twice, write the helper once. This version keeps each key paired with its own value type instead of flattening both into unions:
function entries<T extends object>(obj: T): { [K in keyof T]: [K, T[K]] }[keyof T][] {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]
}
const server = { host: 'localhost', port: 5432 }
for (const [key, value] of entries(server)) {
// ['host', string] | ['port', number]
if (key === 'port') {
console.log(value.toFixed(0)) // ✅ value narrowed to number
}
}The type is a mapped type indexed by keyof T, which turns
{ host: string; port: number } into the union ['host', string] | ['port', number]. Because that
is a union of tuples rather than a tuple of unions, destructuring gives you a
discriminated pair — narrowing on key narrows value
with it. That is the part the cast in fix 1 cannot give you.
The honest caveat: the assertion inside the helper has the same soundness hole as before. You have not made it safe, you have moved the lie to one reviewed line instead of scattering it across the codebase, and you got correlated key/value types out of the deal.
Fix 3: stop needing the keys
Often the right answer is that your object was never a fixed shape — it is a dictionary, and
string keys are the truth. Say so in the type and the problem disappears:
const wordCounts: Record<string, number> = { the: 12, quick: 3 }
for (const [word, count] of Object.entries(wordCounts)) {
// word: string ✅ correct, nothing was lost
console.log(word, count.toFixed(0)) // count: number ✅
}Record<string, number> gets you precise values with no cast at all, because T infers cleanly
from the index signature. Note what that buys you compared to the mixed-value object earlier:
count is number, not a union, so you can call number methods on it directly.
If you want a fixed key union instead, Record<'a' | 'b', number> gives you that, and then fix 1
applies. The Record page covers the difference, and
utility types puts it next to the rest of the built-ins.
Entries on arrays and strings
The second half of the signature — ArrayLike<T> — means Object.entries accepts arrays too. The
indices come back as strings, which is the usual surprise:
const colours = ['red', 'green']
const indexed = Object.entries(colours)
// [['0', 'red'], ['1', 'green']] — [string, string][]For arrays you almost always want Array.prototype.entries instead, which yields real numeric
indices and is properly typed:
const palette = ['red', 'green']
for (const [index, colour] of palette.entries()) {
console.log(index.toFixed(0), colour) // index: number ✅
}Same method name, different object, different types. Object.entries is for objects; reach for the
array method when you are iterating an array. Strings work with Object.entries too, for the same
ArrayLike reason, and the result is just as rarely what you wanted — Object.entries('hi') gives
[['0', 'h'], ['1', 'i']]. Spreading the string or using for...of reads better every time.
The fromEntries round trip
Object.fromEntries is the inverse, and it drops keys on the way back:
const prices = { apple: 1, banana: 2 }
const doubled = Object.fromEntries(
Object.entries(prices).map(([key, value]) => [key, value * 2]),
)
// { [k: string]: number } — the key union is gone againIts signature is fromEntries<T>(entries: Iterable<readonly [PropertyKey, T]>): { [k: string]: T }.
Same story as entries: T is inferred, the keys are not. To keep the shape across a round trip,
annotate the result:
const rates = { usd: 1, eur: 0.92 }
const inverted = Object.fromEntries(
Object.entries(rates).map(([key, value]) => [key, 1 / value]),
) as Record<keyof typeof rates, number>
console.log(inverted.eur.toFixed(2)) // ✅ eur is knownThis entries → map → fromEntries sandwich is the standard way to transform every value in an
object, and it is worth knowing that the cast belongs on the outside of it. Casting the intermediate
array instead gives the compiler more to check and buys you nothing.
One detail that causes a real error here: fromEntries wants entries shaped
readonly [PropertyKey, T]. When you build the pairs by hand rather than mapping over
Object.entries, the array literal widens to (string | number)[] and stops fitting. as const
pins it:
const literalPairs = [
['id', 1],
['score', 2],
] as const
const built = Object.fromEntries(literalPairs) // { [k: string]: 1 | 2 } ✅If rebuilding objects from tuples at the type level sounds useful, the Tuple to Object challenge is the type-level version of this exact operation.
Object.entries needs ES2017
If the method is missing entirely, this is the error:
Property 'entries' does not exist on type 'ObjectConstructor'.
Do you need to change your target library?
Object.entries and Object.values landed in ES2017, Object.fromEntries in ES2019. Your
tsconfig.json needs a target of es2017 or later, or an explicit lib that includes it:
{
"compilerOptions": {
"target": "es2016",
"lib": ["ES2019", "DOM"]
}
}This is a types-only problem. Every runtime you are likely to ship to has had all three for years,
so bumping lib is normally enough and needs no polyfill.
Summary
Object.entries gives you [string, T][], and both halves of that are worth remembering:
- The key is
stringbecause object types are not sealed — a value can carry properties its type does not mention, so akeyof Tkey would be unsound. - The value is a union of every value type in the object, which is why
value.toFixed()fails on a mixed object even when you checked the key. - Cast with
keyof typeofwhere the literal is written, use the mapped-type helper when you need key and value to stay correlated, and reach forRecordwhen the object was a dictionary all along.
The same "method plus a lossy signature" pattern shows up elsewhere in the standard library — flatMap is the other one worth reading, where the surprise is tuples widening instead of keys.
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