Search code examples
typescript

Invalid Argument of type 'WeakMap<T, any>' is not assignable to parameter of type 'MapType<T, any>'


The following leads to an error with this.#map:

type MapType<K, V> = K extends object ? (Map<K, V> | WeakMap<K,V>) : Map<K,V>

export function getOrSet<K, V> (map: MapType<K, V>, key: K, create: (k: K) => V): V {
    if (!map.has(key)) { map.set(key, create(key)) }
    return map.get(key)
}

export class Select<T extends object>  {
  #map = new WeakMap<T, any>()
    observable (item: T) { return getOrSet(this.#map, item, () => 'any') }
}

Error

  1. Playground
  2. Playground with K & object

But this is incorrect because T extends object and the error goes away when we change it to WeakMap<object, any>.

Is there any fix/workaround here?


Noting that using an overload or K & object moves the error into the getOrSet:

error in getOrSet


Solution

  • TypeScript isn't really able to do much analysis on generic types, especially conditional types that depend on generics. Generic conditional types like MapType<T, any> are essentially deferred by the compiler. It doesn't use the fact that T is constrained to object to partially evaluate it. There is a longstanding open feature request at microsoft/TypeScript#23132 to change this, but so far it's not part of the language. Until and unless that happens, you'll have to work around it.


    One simple workaround, given your example code, is to widen T to object when you call getOrSet():

    export class Select<T extends object> {
      #map = new WeakMap<T, any>()
      observable(item: T) { return getOrSet<object, any>(this.#map, item, () => 'any') }
    }
    

    But if you need things to remain generic, this isn't very useful.


    One approach which often helps is to forget about trying to constrain map to either Map<K, V> or WeakMap<K, V>, but just write out MapType<K, V> to capture only the functionality you actually care about. If the implementation of getOrSet() only calls get() and set() (or has()) then you can declare MapType<K, V> so that it only includes get() and set() (or has()):

    interface MapType<K, V> {
      get(k: K): V | undefined;
      set(k: K, v: V): MapType<K, V>;
      // has(k: K): boolean;
    }
    

    Nothing there needs to care about whether K is object-like or not, because that really isn't important for what you're doing. Then the implementation works as desired:

    export function getOrSet<K, V>(map: MapType<K, V>, key: K, create: (k: K) => V): V {
      let v = map.get(key);
      if (typeof v === "undefined") {
        v = create(key);
        map.set(key, v);
      }
      return v;
    }
    

    And now you don't have anything conditional going on at all. When you call getOrSet(), TypeScript sees this.#map is being of type MapType<T, any>:

    export class Select<T extends object> {
      #map = new WeakMap<T, any>()
      observable(item: T) { return getOrSet(this.#map, item, () => 'any') }
    }
    

    And you can see that both Map and WeakMap are supported by MapType naturally:

    const k = { a: "abc" };
    
    const m = new WeakMap<{ a: string }, { b: number }>();
    const v = getOrSet(m, k, ({ a }) => ({ b: a.length })); // okay
    console.log(v);
    
    const m2 = new Map<{ a: string }, { b: number }>();
    const v2 = getOrSet(m2, k, ({ a }) => ({ b: a.length })); // okay
    console.log(v2);
    

    Note, you could decide to overload the function to give it exactly the call signatures you want to support, assuming you really don't want to accept things other than Map or WeakMap:

    export function getOrSet<K extends object, V>(map: Map<K, V> | WeakMap<K, V>, key: K, create: (k: K) => V): V;
    export function getOrSet<K, V>(map: Map<K, V>, key: K, create: (k: K) => V): V;
    export function getOrSet<K, V>(map: MapType<K, V>, key: K, create: (k: K) => V) {
      let v = map.get(key);
      if (typeof v === "undefined") {
        v = create(key);
        map.set(key, v);
      }
      return v;
    }
    

    But I don't know that this buys you very much. Overload implementations are not checked strictly against each call signature (see microsoft/TypeScript#13235) so you need to be careful. Overloads have other caveats, so unless the added complexity is important to you, I'd avoid them.

    Playround link to code