I have encountered a seemingly intractable problem in Typescript. If I construct a generic collection whose shape is defined outside the class, Typescript correctly understands the types when referencing its values directly, but not when using a getter function.
In this example, I want a collection where a developer must define the keys for what is in it but can give it almost any shape desired.
First, I've defined a generic class for entries in the collection:
class Entry<T extends string | number> {
value?: T;
constructor(value: T) {
this.value = value;
}
}
Next, I've defined a class for the collection itself:
export type CollectionState<
Keys extends { [key: string]: string },
> = {
[key in Keys[keyof Keys]]: Entry<string> | Entry<number>;
};
class Collection<
Keys extends { [key: string]: string },
State extends CollectionState<Keys>,
> {
state: State;
constructor(initialState: State) {
this.state = initialState;
}
}
And here, I new up a collection for testing it:
enum MyKeys {
KEY1 = 'key1',
KEY2 = 'key2',
}
type MyKeysType = typeof MyKeys;
// Define a shape for the collection
interface StateDefinition {
[MyKeys.KEY1]: Entry<string>,
[MyKeys.KEY2]: Entry<number>,
}
// Define an initial state for the collection
const initialState: StateDefinition = {
[MyKeys.KEY1]: new Entry<string>('key1'),
[MyKeys.KEY2]: new Entry<number>(2),
};
// Create a new instance of the collection
const collection = new Collection<
MyKeysType,
StateDefinition
>(initialState);
So far, everything checks out. When I reference variables within the collection and inspect them, I see that key1
is an Entry<string>
type and key2
is Entry<number>
type.
// All checks out
const test1 = collection.state[MyKeys.KEY1]; // type is Entry<string>
const test2 = collection.state.key1; // type is Entry<string>
const test3 = collection.state[MyKeys.KEY2]; // type is Entry<number>
const test4 = collection.state.key2; // type is Entry<number>
The problem happens when I try to incorporate a function for getting data. I've extended a new class in this example, but the issue also occurs with or without extending.
class ExtendedCollection<
Keys extends { [key: string]: string },
State extends CollectionState<Keys>,
> extends Collection<Keys, State> {
getEntry(key: Keys[keyof Keys]) {
return this.state[key];
}
}
I expected that Typescript would correctly infer the types when calling this function, but not so.
const collection2 = new ExtendedCollection<
MyKeysType,
StateDefinition
>(initialState);
// All good
const test5 = collection2.state[MyKeys.KEY1]; // type is Entry<string>
const test6 = collection2.state.key1; // type is Entry<string>
const test7 = collection2.state[MyKeys.KEY2]; // type is Entry<number>
const test8 = collection2.state.key2; // type is Entry<number>
// FAIL!!!
const test9 = collection2.getEntry(MyKeys.KEY1); // type is Entry<number> | Entry<string>
const test10 = collection2.getEntry(MyKeys.KEY2); // type is Entry<number> | Entry<string>
I cannot even specify the exact type it should be:
/*
TS2322: Type 'Entry<string> | Entry<number>' is not assignable to type 'Entry<string>'.
Type 'Entry<number>' is not assignable to type 'Entry<string>'.
Type 'number' is not assignable to type 'string'.
*/
const test11: Entry<string> = collection2.getEntry(MyKeys.KEY1);
/*
TS2322: Type 'Entry<string> | Entry<number>' is not assignable to type 'Entry<number>'.
Type 'Entry<string>' is not assignable to type 'Entry<number>'.
Type 'string' is not assignable to type 'number'.
*/
const test12: Entry<number> = collection2.getEntry(MyKeys.KEY2);
The last thing I tried was to create some overloads of the getEntry
function.
class ExtendedCollection2<
Keys extends { [key: string]: string },
State extends CollectionState<Keys>,
> extends Collection<Keys, State> {
getEntry(key: Keys[keyof Keys]): Entry<number>
getEntry(key: Keys[keyof Keys]): Entry<string>
getEntry(key: Keys[keyof Keys]): any {
return this.state[key];
}
}
It builds, but I still do not get the desired effect. Now I see both values returned as Entry<number>
even though one of them should be Entry<string>
.
const collection3 = new ExtendedCollection2<
MyKeysType,
StateDefinition
>(initialState);
// All good
const test13 = collection3.state[MyKeys.KEY1]; // type is Entry<string>
const test14 = collection3.state.key1; // type is Entry<string>
const test15 = collection3.state[MyKeys.KEY2]; // type is Entry<number>
const test16 = collection3.state.key2; // type is Entry<number>
// Does not infer the correct type based on the key provided
const test17 = collection3.getEntry(MyKeys.KEY1); // type is Entry<number>
const test18 = collection3.getEntry(MyKeys.KEY2); // type is Entry<number>
/*
TS2322: Type 'Entry<string> | Entry<number>' is not assignable to type 'Entry<string>'.
Type 'Entry<number>' is not assignable to type 'Entry<string>'.
Type 'number' is not assignable to type 'string'.
*/
const test19: Entry<string> = collection3.getEntry(MyKeys.KEY1);
// Okay
const test20: Entry<number> = collection3.getEntry(MyKeys.KEY2);
How do I create a function that can grab an entry but not return it as a union of all the potential types within the collection?
CodePudding user response:
TypeScript will not infer any types, unless the function is generic. The overloads in your question don't really do anything, as both overloads have the same parameter types. TypeScript will choose the first one that fits the argument which leads to the return type always being Entry<number>
.
To get the desired behaviour, we need to make the type of key
generic.
class ExtendedCollection<
Keys extends { [key: string]: string },
State extends CollectionState<Keys>,
> extends Collection<Keys, State> {
getEntry<K extends Keys[keyof Keys]>(key: K) {
return this.state[key];
}
}
The type of the argument passed to getEntry
will be used to infer the type of K
. We don't need to annotate a return type as TypeScript kindly infers the type State[K]
for us.
const collection = new ExtendedCollection<MyKeysType, StateDefinition>(
initialState
);
const test1 = collection.getEntry(MyKeys.KEY1);
// ^? const test1: Entry<string>
const test2 = collection.getEntry(MyKeys.KEY2);
// ^? const test2: Entry<number>