Object.entries in TypeScript

September 17, 202611 min read
Requirements:
ObjectsArraysGenericsMapped Types

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 invisible

Property 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 wrong

The 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 again

Its 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 known

This entries → mapfromEntries 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 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.

Share this article

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

Practice with Challenges

Put your object.entries in typescript knowledge to the test with these related challenges.

#11Tuple to Object
Easy
#527Append to object
Medium
#7258Object Key Paths
Hard

Related Concepts

Concepts that build on or relate to object.entries in typescript.

TypeScript RecordTypeScript typeofMapped TypesTypeScript Utility TypesflatMap in TypeScript