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)
Motivation
The default typescript type for Object.fromEntries
definition looks like this
interface ObjectConstructor {
// ...
fromEntries(entries: Iterable<readonly any[]>): any;
}
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.
Basic static types inferring: https://dev.to/svehla/typescript-inferring-stop-writing-tests-avoid-runtime-errors-pt1-33h7
More advanced generics https://dev.to/svehla/typescript-generics-stop-writing-tests-avoid-runtime-errors-pt2-2k62
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
Preview
type T4 = string | number
type T5 = Cast<T4, string>
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 }
type ResFromEV1 = FromEntriesV1<[
['key1', 'value1'],
['key2', 3],
]>
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
Preview
type T1 = ArrayElement<['foo', 'bar']>
const data = ['foo', 'bar'] as const
type Data = typeof data
type T2 = ArrayElement<Data>
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 }
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;
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 }
Preview
type Result = FromEntries<[
['key1', 'value1'],
['key2', 3],
]>
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>
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]> }
Preview
const data = ['foo', 'bar'] as const
type Data = typeof data
type T3 = DeepWriteable<Data>
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>>
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>
}
}
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
Top comments (8)
This is a GREAT article! Near miss in one small spot:
Cast
should be used to cast toPropertyKey
instead ofstring
. Thennumber
andsymbol
will work as well!Example here:
typescriptlang.org/play?#code/PTAE...
wow, very nice! 😎 thanks for sharing
Why isn't this type just built-in for
Object.fromEntries
? 🤨You should consider making a pull-request to add these types.
Good article. Built-in generics, especially DOM-related, sadly aren't very good.
Btw, you can replace the
ArrayElement<T>
generic with a simpleT[number]
.Hah, now I'm using T[number] 100% of the time. Thanks for the hint!
Hey this is super cool! I have two question if you don't mind.
{key1: 1, key2: 2} as const
to[['key1', 1], ['key2', 2]] as const
. It seems like this is impossible with Object.entries since the types are lost.[['key1', 1], ['key2', ['key2_1',2.1]]] as const
Wow, awesome!
How about a follow-up with refining Object.entries types so that
Object.fromEntries(Object.entries(...))
retains the same type?there you go