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[];
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"
);