Issue
Given some types and data like the following,
how can I hint the types in a way that I get autocompletion for the keys, i.e. dictionary.Germany
?
type Entry = {
tld: string;
name: string;
population: number;
};
const data: Entry[] = [
{tld: 'de', name: 'Germany', population: 83623528},
{tld: 'at', name: 'Austria', population: 8975552},
{tld: 'ch', name: 'Switzerland', population: 8616571}
];
let dictionary = Object.fromEntries(data.map(item => [item.name, item]));
(Now dictionary
is of type { [key: string]: Entry }
, e.g. { Germany: {tld: 'de', …}, …}
.)
To be clear, my objectives are:
- The end result is to have the data in both a list of Entry objects…
- …and an object map with the Entry objects.
- I need to write the name of an object only once, either as
name
or key. - The dictionary needs to know its keys in an IDE like WebStorm.
dictionary.Germany === data[0]
Solution
There are a number of problems preventing your code from working as-is.
First, you annotated the type of data
as Entry[]
, thereby telling the compiler to throw away any more specific information about the initializing array literal. Entry
only knows that name
is string
. But you care about the string literal types of the name
properties.
Instead of annotating, you should probably use a const
assertion to tell the compiler that you want it to keep track of all the literal types of the initializer (this is overkill if all you care about is name
but it doesn't necessarily hurt anything). Then, if you want to be sure that it matches Entry[]
, you can use the satisfies
operator:
const data = [
{ tld: 'de', name: 'Germany', population: 83623528 },
{ tld: 'at', name: 'Austria', population: 8975552 },
{ tld: 'ch', name: 'Switzerland', population: 8616571 }
] as const satisfies Entry[];
(The above compiles only with TypeScript 5.3 or above due to microsoft/TypeScript#55229; in TypeScript 4.9 to 5.2 you'd need to use readonly Entry[]
instead.)
Now TS knows about "Germany"
, "Austria"
, and "Switzerland"
, but it doesn't have any idea that the Object.fromEntries()
method produces a value with those keys. The current type definition looks like
interface ObjectConstructor {
fromEntries<T = any>(e: Iterable<readonly [PropertyKey, T]>): { [k: string]: T; };
}
meaning that its output always has a string
index signature. It's not impossible to write a more specific call signature, as was suggested in microsoft/TypeScript#35745, but it's tricky to get it right and wasn't seen as worth the complexity.
If you want to write such a signature yourself you can do so in your own code base and merge it in like this:
// declare global {
interface ObjectConstructor {
fromEntries<E extends readonly [PropertyKey, any][]>(
entries: E
): { [T in E[number] as T[0]]: T[1] };
}
// }
That iterates over the elements of entries
and uses key remapping to produce an object type.
After this you're pretty much done. Still, the resulting type of dictionary
will have this awful union of const
-asserted junk as its property value type, when all you really want is Entry
. So we can use item satifies Entry as Entry
to safely widen item
to Entry
(writing item satisfies Entry
would check it but not widen it, and writing item as Entry
would widen it but not check it).
So, finally, that gives us:
let dictionary =
Object.fromEntries(data.map(item => [item.name, item satisfies Entry as Entry]));
/* let dictionary: {
Germany: Entry;
Austria: Entry;
Switzerland: Entry;
} */
Now you have exactly the behavior you wanted, more or less. Is it worth it? I suppose that depends on your use cases.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.