DEV Community

loading...
Cover image for Typescript — How to Object.fromEntries tuples

Typescript — How to Object.fromEntries tuples

svehla profile image Jakub Švehla Updated on ・5 min read

Step by step tutorial on how to create a proper type for Object.fromEntries() which can work with tuples and read-only data structures.

TLDR:

Source code for Object.fromEntries type generic is at bottom of the article.
You can copy-paste it into your IDE and play with it.

VS-code preview


const data = [
  ['key1', 'value1' as string],
  ['key2', 3]
]  as const

const result = Object.fromEntries(data)
Enter fullscreen mode Exit fullscreen mode

Alt Text

Motivation

The default typescript type for Object.fromEntries definition looks like this

interface ObjectConstructor {
  // ...
  fromEntries(entries: Iterable<readonly any[]>): any;
}
Enter fullscreen mode Exit fullscreen mode

As you can see the usage of return value : any it's not the best one. So we will redeclare static types for this method via the usage of the strongest Typescript tools which are described below.

Prerequisite

before we will continue we have to know the typescript keyword infer and some basic generics usage.
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types

If you want to deep dive into advanced typescript types I recommend this typescript series full of useful examples.


Let's start hacking

First of all, we will define Cast<X, Y> generic which helps us to build our target FromEntries<T> type.

Cast<X, Y>

This generic help us to bypass the typescript compiler for passing invalid types. We will use Cast<X, Y> to "shrink" a union type to the other type which is defined as the second parameter.

type Cast<X, Y> = X extends Y ? X : Y
Enter fullscreen mode Exit fullscreen mode

Preview

type T4 = string | number
type T5 = Cast<T4, string>
Enter fullscreen mode Exit fullscreen mode

Alt Text

Okay... it should be enough for this moment. We can start with the FromEntries<T> generic.

FromEntries<T>

So let's define a new type FromEntriesV1<T>. It takes one argument T and checks if the argument is a two-dimensional matrix [any, any][] if yes, create proper type. if no return default behavior which just returns unknown untyped Object { [key in string]: any }.

type FromEntriesV1<T> = T extends [infer Key, any][]
  // Cast<X, Y> ensure TS Compiler Key to be of type `string`
  ? { [K in Cast<Key, string>]: any }
  : { [key in string]: any } 
Enter fullscreen mode Exit fullscreen mode
type ResFromEV1 = FromEntriesV1<[
  ['key1', 'value1'],
  ['key2', 3],
]>
Enter fullscreen mode Exit fullscreen mode

Alt Text

It works the same even without Cast<Key, string> generic but Typescript compiler still warning you that there is a potential error so we have to bypass it with the Cast<X, Y>

This generic works thanks to infer which extracts out all keys into a union type which is used as target object keys.

Now we have to set the correct values of the object but before we will do it let's introduce another generics ArrayElement<A>.

ArrayElement<A>

this simple generic helps us to extract data outside of an Array<T> wrapper.

export type ArrayElement<A> = A extends readonly (infer T)[]
  ? T
  : never
Enter fullscreen mode Exit fullscreen mode

Preview

type T1 = ArrayElement<['foo', 'bar']>
Enter fullscreen mode Exit fullscreen mode
const data = ['foo', 'bar'] as const
type Data = typeof data
type T2 = ArrayElement<Data>
Enter fullscreen mode Exit fullscreen mode

Alt Text

Okey we can continue with adding proper value into the new object. We just simply set that value is second item of nested tuple ArrayElement<T>[1].

type FromEntriesV2<T> = T extends [infer Key, any][]
  ? { [K in Cast<Key, string>]: ArrayElement<T>[1] }
  : { [key in string]: any }
Enter fullscreen mode Exit fullscreen mode

Alt Text

we successfully extracted all possible values but as we can see, there is a missing connection between key and value in our new type.

If we want to fix it we have to know another generic Extract<T>. Extract<T> is included in the official standard typescript library called utility-types.

This generic is defined as:

type Extract<T, U> = T extends U ? T : never;
Enter fullscreen mode Exit fullscreen mode

official documentation: https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-union

Thanks to this generic we can create connections between keys and values of nested tuples

type FromEntries<T> = T extends [infer Key, any][]
  ? { [K in Cast<Key, string>]: Extract<ArrayElement<T>, [K, any]>[1] }
  : { [key in string]: any }
Enter fullscreen mode Exit fullscreen mode

Preview

type Result = FromEntries<[
  ['key1', 'value1'],
  ['key2', 3],
]>
Enter fullscreen mode Exit fullscreen mode

Alt Text

And... that's all!!! Good job! we did it 🎉 now the generics can transfer an Array of tuples into object type.


Oh, wait. there is still some major issues which we should solve

Generic does not work well with readonly Notations like in the example below

const data = [['key1', 1], ['key2', 2]] as const
type Data = typeof data
type Res = FromEntries<Data>
Enter fullscreen mode Exit fullscreen mode

Alt Text

Alt Text

To resolve this issue let's introduce another generic DeepWriteable

DeepWriteable<T>

this generic is used to recursively remove all readonly notations from the data type.
If you create type by typeof (data as const) all keys start with the readonly prefix so we need to remove it to make all objects consistent.

type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> }
Enter fullscreen mode Exit fullscreen mode

Preview

const data = ['foo', 'bar'] as const
type Data = typeof data
type T3 = DeepWriteable<Data>
Enter fullscreen mode Exit fullscreen mode

Alt Text

With this new knowledge, we can fix unexpected behavior and make it all works again.

const data = [['key1', 1], ['key2', 2]] as const
type Data = typeof data

type T6 = FromEntries<DeepWriteable<Data>>
Enter fullscreen mode Exit fullscreen mode

Alt Text

Final source code + Redeclare global Object behavior

If you don't know what declare {module} annotations in typescript is, You can check official documentation https://www.typescriptlang.org/docs/handbook/modules.html

We will use this feature to redeclare the global type behavior of Object.fromEntries.

All you need to do is just paste the code below to your index.d.ts or global.d.ts.


export type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type Cast<X, Y> = X extends Y ? X : Y
type FromEntries<T> = T extends [infer Key, any][]
  ? { [K in Cast<Key, string>]: Extract<ArrayElement<T>, [K, any]>[1]}
  : { [key in string]: any }

export type FromEntriesWithReadOnly<T> = FromEntries<DeepWriteable<T>>


declare global {
   interface ObjectConstructor {
     fromEntries<T>(obj: T): FromEntriesWithReadOnly<T>
  }
}
Enter fullscreen mode Exit fullscreen mode

And voilá 🎉 🎉 🎉 🎉 🎉 🎉
We are done

I hope that you enjoyed this article the same as me and learned something new. If yes don't forget to like this article

Discussion (2)

pic
Editor guide
Collapse
dwelle profile image
David Luzar • Edited

Good article. Built-in generics, especially DOM-related, sadly aren't very good.

Btw, you can replace the ArrayElement<T> generic with a simple T[number].

Collapse
svehla profile image
Jakub Švehla Author

Hah, now I'm using T[number] 100% of the time. Thanks for the hint!