Properly type Object.keys and Object.entries

Picture of the author
Nicolas CharpentierAugust 07, 2023
4 min read
Properly type Object.keys and Object.entries

Have you ever noticed that Object.keys and Object.entries are a little bit tricky to work with in TypeScript? They won't return what you would expect, even with a readonly object.

Let's have a peek at it.

For the following object combined to a const assertion to allow TypeScript to take the most specific type of the expression and set properties to readonly:

const data = {
  a: 'value-a',
  b: 'value-b',
  c: 'value-c',
} as const;

You would expect Object.values to return the literal values of the object, and you'll be right:

const values = Object.values(data);
//    ^? const values: ("value-a" | "value-b" | "value-c")[]

But what about Object.keys and Object.entries?

Object.keys

Calling Object.keys with our object returns string[]:

const keys = Object.keys(data);
//    ^? const keys: string[]

And this is by design! Object.keys always returns string[]:

/**
 * Returns the names of the enumerable string properties and methods of an object.
 * @param o Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
 */
keys(o: {}): string[];

🔗 Source.

This is because types are intentionally open-ended in TypeScript so it can't always guarantee that your object types don't contain excess properties, even when defined with a const assertion. 😥

It may be be different when Record (a deeply immutable Object-like structure) lands. 🤞

Solution

Fortunately, TypeScript offers a keyof type operator that returns the type of the keys of a given type:

⚠️ Be mindful that this is only effective if you know the object is immutable and won't contain any extra properties.

// Type is followed by `& {}` so we could simplify the type as the actual content rather than just displaying `Keys`
type Keys = (keyof typeof data)[] & {};
//   ^? type Keys = ("a" | "b" | "c")[]

Then, once we captured what are the possible values of the keys, we can cast the result of Object.keys to our type:

const typedKeys = Object.keys(data) as Keys;
//    ^? const typedKeys: ("a" | "b" | "c")[]

Or alternatively, the inlined version:

const typedKeys = Object.keys(data) as (keyof typeof data)[];
//    ^? const typedKeys: ("a" | "b" | "c")[]

We can even push it a little bit further and create a generic function that will wrap this up for us:

function keysFromObject<T extends object>(object: T): (keyof T)[] {
  return Object.keys(object) as (keyof T)[];
}

const typedKeys = keysFromObject(data);
//    ^? const typedKeys: ("a" | "b" | "c")[]

Object.entries

The same thing applies to Object.entries.

value works as expected, but key is typed as a string:

const entries = Object.entries(data).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: string
);

Solution

We can still leverage the keyof type operator combined with a generic type to capture the keys of the object and then cast the result of Object.entries to our type:

type Entries<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T][];

const typedEntries = (Object.entries(data) as Entries<typeof data>).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

Again, we can push this a little further by either using Entries from type-fest:

import type { Entries } from 'type-fest';

const typedEntries = (Object.entries(data) as Entries<typeof data>).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

Or by creating our own generic function:

function entriesFromObject<T extends object>(object: T): Entries<T> {
  return Object.entries(object) as Entries<T>;
}

const typedEntries2 = entriesFromObject(data).map(
  ([key, value]) => [key, value],
  // ^? (parameter) key: "a" | "b" | "c"
);

TypeScript Playground

🔗 TypeScript Playground.