Home > Blockchain >  Typescript: How to return a specific type from class properties with Map/Record type values?
Typescript: How to return a specific type from class properties with Map/Record type values?

Time:06-15

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].

Playground link to code

  • Related