I have the following class DataStore
with two properties of Map types. I am trying to come up with a method getEntity
that will return proper data type based on parameter. Also trying to create a method updateEntity
for updating the properties.
type TUUID = string;
interface IUser {
name: string;
type: 'customer' | 'client';
}
interface IProduct {
name: string;
count: number;
type: 'electronics' | 'food' | 'drinks';
}
const _user: IUser = { name: 'John Doe', type: 'customer' };
interface IDataStore {
users: Map<TUUID, IUser>;
products: Map<TUUID, IProduct>;
}
export class DataStore implements IDataStore {
users: IDataStore['users'] = new Map();
products: IDataStore['products'] = new Map();
getEntity<T extends keyof IDataStore>(storeKey: T, id: TUUID) {
if (this[storeKey] && this[storeKey].has(id)) return this[storeKey].get(id);
return null;
}
updateEntity<T extends keyof IDataStore>(storeKey: T, id: TUUID, data: ReturnType<IDataStore[T]['get']>) {
if (this[storeKey]) {
this[storeKey].set(id, data);
return this[storeKey].get(id);
} else {
return null;
}
}
}
const store = new DataStore();
const user = store.getEntity('users', '1234');
const product = store.getEntity('products', 'abcd');
const updatedUser = store.updateEntity('users', '1234', _user);
But typescript infer data type for getEntity
as IUser | IProduct | null | undefined
. When trying to access product.count
, it provides the following error:
Property 'count' does not exist on type 'IUser | IProduct'.
Property 'count' does not exist on type 'IUser'.
Also, this line this[storeKey].set(id, data);
provides the error:
(parameter) data: IUser | IProduct | undefined
Argument of type 'IUser | IProduct | undefined' is not assignable to parameter of type 'never'.
The intersection 'IUser & IProduct' was reduced to 'never' because property 'type' has conflicting types in some constituents.
Type 'undefined' is not assignable to type 'never'.ts(2345)
If I do method overloading, it mostly works. But then the method parameters inside the method become any
and that is error prone. I tried with Record and just pure objects, still have the same issue. However, if I also do methods for specific property like getUser
, getProduct
, updateUser
and updateProduct
it does infer the correct type.
Is there any way to have a generic method that will properly infer the data type?
CodePudding user response:
First, in order for this[storeKey].set(id, data)
to work you need to refactor the types of this
and data
in order for the compiler to see them as obviously compatible with each other. The general problem of something like this[storeKey].set()
and data
having correlated union types is described in microsoft/TypeScript#30581, and the fix as implemented and described in microsoft/TypeScript#47109 is to refactor to distributive object types.
For example, here's a way to rewrite IDataStore
:
interface DataMap {
users: IUser;
products: IProduct;
}
type IDataStore = { [K in keyof DataMap]: Map<TUUID, DataMap[K]> }
This looks like a no-op, and indeed if you inspect IDataStore
, it looks the same as before:
/*type IDataStore = {
users: Map<TUUID, IUser>;
products: Map<TUUID, IProduct>;
}*/
But the important thing here is that the compiler knows that IDataStore
acts generically over K extends keyof DataMap
in a way that it does not know for your original definition. In updateEntity
we also change the type of data
to DataMap[K]
instead of ReturnType<IDataStore[K]['get']>
. The former is seen as related to the IDataStore
definition for generic K
, while the latter is too complicated and uses the ReturnType<T>
utility type which is implemented as a conditional type.
So here's what we have so far:
getEntity<K extends keyof IDataStore>(storeKey: K, id: TUUID) {
return this[storeKey].get(id) ?? null;
} // returns IUser | IProduct | null
updateEntity<K extends keyof IDataStore>(storeKey: K, id: TUUID, data: DataMap[K]) {
this[storeKey].set(id, data); // error! Argument of type
// 'IUser | IProduct' is not assignable to parameter of type 'never'
return data;
}
(Note that I removed your presence/absence test, since your properties are not optional). This isn't obviously an improvement; the output type of getEntity()
is still a union, and the set()
method still complains about the intersection. The problem now is that the type of this
is the polymorphic this
type and it treated by the compiler like an implicit generic type parameter. It automatically represents the type of the implementing subclass. This confuses the compiler and it cannot see the connection to IDataStore
. The compiler views this[storeKey]
as a union type because it just widens this
to its DataStore
class constraint. We want to work in terms of IDataStore
which is specifically built to maintain the correlation.
My approach here is to safely widen this
to IDataStore
before accessing the storeKey
property:
getEntity<K extends keyof IDataStore>(storeKey: K, id: TUUID) {
const thiz: IDataStore = this;
return thiz[storeKey].get(id) ?? null;
} // returns NonNullable<DataMap[K]> | null
updateEntity<K extends keyof IDataStore>(storeKey: K, id: TUUID, data: DataMap[K]) {
const thiz: IDataStore = this;
thiz[storeKey].set(id, data); // okay
}
Now everything works. The type of thiz[storeKey]
is now represented in terms of the generic K
type parameter, so that get()
returns DataMap[K] | undefined
and set()
accepts a DataMap[K]
.