DEV Community

harry
harry

Posted on • Edited on

More powerful type definitions for Object.entries()

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>>;
}
Enter fullscreen mode Exit fullscreen mode

Examples of type definition usage

Calling typedEntries() function, which wraps Object.entries(), returns an array of entry types inferred according to the argument type.

TypeScript: TS Playground

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"; }]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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>
Enter fullscreen mode Exit fullscreen mode

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 of ReadonlyArray<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]" 
Enter fullscreen mode Exit fullscreen mode

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][]
Enter fullscreen mode Exit fullscreen mode

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>>
  }
}
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()

20.1.2.5 Object.entries ( O )

When the entries function is called with argument O, the following steps are taken:

  1. Let obj be ? ToObject(O).
  2. Let nameList be ? EnumerableOwnPropertyNames(obj, key+value).
  3. Return CreateArrayFromList(nameList).

7.3.24 EnumerableOwnPropertyNames ( O, kind )

It performs the following steps when called:

  1. Let ownKeys be ? O.[[OwnPropertyKeys]]().
  2. Let properties be a new empty List.
  3. For each element key of ownKeys, do
    • a. If Type(key) is String, then

6.1.7 The Object Type

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).

6.1.5 The Symbol Type

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

Articles for Type-Level Programming in TS

English

Japanese

Top comments (0)