Introduction
Object.entries<T>()
has the following type definition (typing) as standard in TypeScript and returns an array of type [string, T]
.
https://github.com/microsoft/TypeScript/blob/v4.6.2/lib/lib.es2017.object.d.ts#L34-L44
/** * Returns an array of key/values of the enumerable properties 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. */ entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][]; /** * Returns an array of key/values of the enumerable properties 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. */ entries(o: {}): [string, any][];
There is nothing wrong with this definition itself, but there are cases where you want to get a type like <K = keyof T> [`${K}`, T[K]][]
from Object.entries()
when passed an object generated by pure processing without side-effects (*1).
- *1: If it is clear that there are only key-value type combinations defined by type in the object, rather than an object obtained via a side-effect such as the REST API call. (See also #Appendix of this article)
Type definitions
In such cases, the following type definitions can be defined to get the expected type.
type TupleEntry<T extends readonly unknown[], I extends unknown[] = [], R = never> =
T extends readonly [infer Head, ...infer Tail] ?
TupleEntry<Tail, [...I, unknown], R | [`${I['length']}`, Head]> :
R
// eslint-disable-next-line @typescript-eslint/ban-types
type ObjectEntry<T extends {}> =
// eslint-disable-next-line @typescript-eslint/ban-types
T extends object ?
{ [K in keyof T]: [K, Required<T>[K]] }[keyof T] extends infer E ?
E extends [infer K, infer V] ?
K extends string | number ?
[`${K}`, V] :
never :
never :
never :
never
// eslint-disable-next-line @typescript-eslint/ban-types
export type Entry<T extends {}> =
T extends readonly [unknown, ...unknown[]] ?
TupleEntry<T> :
T extends ReadonlyArray<infer U> ?
[`${number}`, U] :
ObjectEntry<T>
// eslint-disable-next-line @typescript-eslint/ban-types
export function typedEntries<T extends {}>(object: T): ReadonlyArray<Entry<T>> {
return Object.entries(object) as unknown as ReadonlyArray<Entry<T>>;
}
Examples of type definition usage
Calling typedEntries()
function, which wraps Object.entries()
, returns an array of entry types inferred according to the argument type.
type StringKeyRecordEntry = Entry<Record<string, boolean>>
// [string, boolean]
type NumberKeyRecordEntry = Entry<Record<number, boolean>>
// [`${number}`, boolean]
type UnionKeyRecordEntry = Entry<Record<'foo' | 'bar', boolean>>
// ['foo', boolean] | ['bar', boolean]
type ObjectType = {
a: number,
b?: string,
c: number | undefined,
d?: string | undefined
}
type ObjectTypeEntry = Entry<ObjectType>
// if disabled exactOptionalPropertyTypes option
// ["a", number] | ["b", string] | ["c", number | undefined] | ["d", string]
// if enabled exactOptionalPropertyTypes option
// ["a", number] | ["b", string] | ["c", number | undefined] | ["d", string | undefined]
const constObject = {
a: "foo",
b: 42,
c: false,
1: { type: "number" }
} as const;
type ConstObjectEntry = Entry<typeof constObject>
// ["a", "foo"] | ["b", 42] | ["c", false] | ["1", { readonly type: "number"; }]
type UnionObject = (
{ gt?: number } &
{ lt?: number }
) |
{ between: [number, number] }
type UnionObjectEntry = Entry<UnionObject>
// ["gt", number] | ["lt", number] | ["between", [number, number]]
const someSymbol = Symbol('some');
interface IPerson {
age?: number;
name: string;
[someSymbol] : Date;
}
type InterfaceEntry = Entry<IPerson>
// ["age", number] | ["name", string]
type ArrayEntry = Entry<Array<number>>
// [`${number}`, number]
type ReadonlyArrayEntry = Entry<ReadonlyArray<boolean>>
// [`${number}`, boolean]
type UnionArrayEntry = Entry<Array<number | boolean>>
// [`${number}`, number | boolean]
type TupleArrayEntry = Entry<[string, number, boolean]>
// ["0", string] | ["1", number] | ["2", boolean]
const constTuple = ["foo", 42, { key: "value" }] as const;
type ConstTupleArrayEntry = Entry<typeof constTuple>
// ["0", "foo"] | ["1", 42] | ["2", { readonly key: "value"; }]
Overview of type definitions
The key-value entry type of any object can be obtained by the following type definition.
type Entry<T> = { [K in keyof T]: [K, T[K]] }[keyof T]
type FooObject = {
a: number,
b: string,
c: number
}
type FooObjectEntry = Entry<FooObject>
// ["a", number] | ["b", string] | ["c", number]
However, this type definition will not return the expected type when used on an object of a union type and an object containing optional keys.
type Entry<T> = { [K in keyof T]: [K, T[K]] }[keyof T]
type UnionObject =ใ{ 1: number } |ใ{ 2: string }
type UnionObjectEntry = Entry<UnionObject>;
// never
type ContainsOptional = {
a: number,
b?: string,
c: number | undefined,
d?: string | undefined
}
type ContainsOptionalEntry = Entry<ContainsOptional>;
// ["a", number] | ["b", string | undefined] | ["c", number | undefined] | ["d", string | undefined] | undefined
So first of all, let's solve the problem that occurs with the union type.
For example, if we define a type like type SingleTuple<T> = T extends infer A ? [A] : never
and pass a single type or a union type to the generic type T
, what type will be returned? In particular, what is the correct type when passing a union type to the generic type T
?
type SingleTuple<T> = T extends infer A ? [A] : never
type StringSingleTuple = SingleTuple<string>
// [string]
type UnionSingleTuple = SingleTuple<string | number>
// [string] | [number]
The correct answer was [A] | [B]
, if T
was union type A | B
(*2).
Just as an example, if a type definition T extends infer A
is defined for a generic type T
, and T
is a union type, the following for
statement is added to the type definition.
pseudo code of this type definition
type T = string | number
let ReturnType = never;
for (t of T) {
if (t extends infer A) {
ReturnType = ReturnType | [A];
} else {
ReturnType = ReturnType | never;
}
}
return ReturnType;
// [string] | [number]
So we add extends
that explicitly states that we will only consider the generic type T
if it is an object
type, so that even if T
is a union type, it will return an entry type for each type contained in the union type.
type ObjectEntry<T extends {}> =
T extends object ?
{ [K in keyof T]: [K, T[K]] }[keyof T] :
never
type UnionObject =ใ{ 1: number } |ใ{ 2: string }
type UnionObjectEntry = ObjectEntry<UnionObject>
// [1, number] | [2, string]
The reason why the line added to the type definition is not T extends Record<>
is because it returns never
if the generic type T
is an interface. In fact, interface does not satisfy the Record<>
type. (See also #Appendix of this article)
Next, let's solve the problem that occurs when an optional key is included.
If the key is optional, the resulting entry type contains undefined
, so we remove it using Exclude<>
. And then the value type contains undefined
, so T
is changed to Required<T>
to get the value type. However, the type obtained from the type definition changes depending on the setting of exactOptionalPropertyTypes
in TS Config.
https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes
type ObjectEntry<T extends {}> =
T extends object ?
Exclude<{ [K in keyof T]: [K, Required<T>[K]] }[keyof T], undefined> :
never
type ContainsOptional = {
a: number,
b?: string,
c: number | undefined,
d?: string | undefined
}
type ContainsOptionalEntry = ObjectEntry<ContainsOptional>
// if disabled exactOptionalPropertyTypes option
// ["a", number] | ["b", string] | ["c", number | undefined] | ["d", string]
// if enabled exactOptionalPropertyTypes option
// ["a", number] | ["b", string] | ["c", number | undefined] | ["d", string | undefined]
Finally, the Entry type we are expecting is a type whose key is String Literal, so we convert it with Template Literal Types. However, since the value of key of type symbol is not included in the return value of Object.entries()
(see also #Appendix of this article), it is unnecessary and should be excluded. In fact, symbol types cannot be implicitly type-converted to string types(*3), and using them in Template Literal Types will result in a compile error.
type SymbolStringLiteral<T extends symbol> = `${T}`
// TS2322: Type 'T' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
Since the currently generated Entry type is a union type, once bound as infer E
, only those Entries whose keys are of type string | number
are extracted and converted using Template Literal Types.
The undefined
in the result are excluded by E extends [infer K, infer V]
, so Exclude<>
is no longer necessary.
type ObjectEntry<T extends {}> =
T extends object ?
{ [K in keyof T]: [K, Required<T>[K]] }[keyof T] extends infer E ?
E extends [infer K, infer V] ?
K extends string | number ?
[`${K}`, V] :
never :
never :
never :
never
In TypeScript 4.7, a new feature called "extends
Constraints on infer
Type Variables" will be added, so that in the future this type definition can be written a little shorter.
type ObjectEntry<T extends {}> =
T extends object ?
{ [K in keyof T]: [K, Required<T>[K]] }[keyof T] extends infer E ?
E extends [infer K extends string | number, infer V] ?
[`${K}`, V] :
never :
never :
never
...Oops, we forgot to consider the case where an array type is passed as a typedEntries()
function argument. For a simple array type Array<T>
, it is enough to return [`${number}`, T]
, however for Tuple, we would like to return a type like <I = 0 | 1 | 2 | ... > [`${I}`, T[I]]
from type definition.
Fortunately, TypeScript allows for zero-based number generation and recursive type definition calls using Type-Level Programming, so type definitions can be defined as follows.
type TupleEntry<T extends readonly unknown[], I extends unknown[] = [], R = never> =
T extends readonly [infer Head, ...infer Tail] ?
TupleEntry<Tail, [...I, unknown], R | [`${I['length']}`, Head]> :
R
export type Entry<T extends {}> =
T extends readonly [unknown, ...unknown[]] ?
TupleEntry<T> :
T extends ReadonlyArray<infer U> ?
[`${number}`, U] :
ObjectEntry<T>
The I
of TupleEntry<>
starts with an empty array, and each recursive call to the type definition increments the length
property by adding one element to it. As a result, a zero-based number can be generated.
When using ArrayLike<infer U>
to determine if an object is an array type or not, unintended type inference will occur with object like the following, so we need to use ReadonlyArray<infer U>
to determine if it is an array type (*4).
- *4:
Array<T>
satisfies all the properties ofReadonlyArray<T>
, so this type definition can determine these types together.
const arrayLikeObject = {
length: 2,
1: "foo",
2: "bar"
} as const;
type ArrayLikeObject = typeof arrayLikeObject
type ArrayEntry<T extends {}> = T extends ArrayLike<infer U> ? [`${number}`, U] : never
type E = ArrayEntry<ArrayLikeObject>
// [`${number}`, "foo" | "bar"]
Object.entries(arrayLikeObject).forEach(entry => { console.log(JSON.stringify(entry)); });
// [LOG]: "["1","foo"]"
// [LOG]: "["2","bar"]"
// [LOG]: "["length",2]"
An empty array returns [`${number}`, never]
, however the result of Object.entries([])
is also [string, never][]
, so there is no special type definition for an empty array.
type EmptyArrayEntry = Entry<[]>
// [`${number}`, never]
const entries = Object.entries([]);
// const entries: [string, never][]
If you have a special reason to return never
for an empty array, you can use type NonEmptyReadonlyArray<T> = readonly [T, ...T[]];
and then you need to change the type definition to something like NonEmptyReadonlyArray<infer U>
.
This completes the type definitions for Object.entries()
.
Appendix
Why did not change Object.entries()
default behavior
You can change the behavior of Object.entries()
itself by defining it as follows:
declare global {
interface ObjectConstructor {
entries<T extends {}>(object: T): ReadonlyArray<Entry<T>>
}
}
However, as mentioned above, objects obtained from processes with side effects or objects cast to types satisfying structural subtypes may not return the expected kay-value combination at runtime.
https://github.com/Microsoft/TypeScript/pull/12253#issuecomment-263132208
const actual = {
key: 'foo',
extended: true
} as const;
type Base = { key: string }
const base: Base = actual;
Object.entries(base);
// Actually returns [['key', 'foo'], ['extended', true]]
Therefore, I am motivated to use it locally only when it is obvious that the expected kay-value combination will be returned, so I define a function that wraps Object.entries()
.
An interface does not extend Record<>
interface IPerson {
name: string;
}
type T = Extract<IPerson, Record<string | number | symbol, unknown>>
// never
Related TypeScript issues
- Types declared as an "interface" do not extend Record #42825
- Types fulfill an interface, but interfaces do not #41518
- Index signature is missing in type (only on interfaces, not on type alias) #15300
Just to fill people in, this behavior is currently by design. Because interfaces can be augmented by additional declarations but type aliases can't, it's "safer" (heavy quotes on that one) to infer an implicit index signature for type aliases than for interfaces. But we'll consider doing it for interfaces as well if that seems to make sense
Type aliases can implicitly fulfill an index signature (which is being declared at the Record usage), but not interfaces (since they're subject to declaration merging)
Object.entries() never returns the value of symbol type keys
Keys of symbol type are non-String values
, so they are not enumerated in EnumerableOwnPropertyNames
and are not included in the return value of Object.entries()
When the entries function is called with argument O, the following steps are taken:
- Let obj be ?
ToObject(O)
.- Let nameList be ?
EnumerableOwnPropertyNames(obj, key+value)
.- Return
CreateArrayFromList(nameList)
.
7.3.24 EnumerableOwnPropertyNames ( O, kind )
It performs the following steps when called:
- Let ownKeys be ?
O.[[OwnPropertyKeys]]()
.- Let properties be a new empty List.
- For each element key of ownKeys, do
- a. If
Type(key)
is String, then
A property key value is either an ECMAScript String value or a Symbol value. All String and Symbol values, including the empty String, are valid as property keys. A property name is a property key that is a String value.
An integer index is a String-valued property key that is a canonical numeric String (see 7.1.21) and whose numeric value is either +0๐ฝ or a positive integral Number โค ๐ฝ(253 - 1).
The Symbol type is the set of all non-String values that may be used as the key of an Object property (6.1.7).
Related links
Object.entries correct typings ยท Issue #35101 ยท microsoft/TypeScript
typescript - Create key/value type from object type - Stack Overflow
types - Typescript: keyof typeof union between object and primitive is always never - Stack Overflow
Typescript Key-Value relation preserving Object.entries type - Stack Overflow
Top comments (0)