typescriptweakmap

Use 2 derived classes as key of a WeakMap


I try to use 2 classes (not instances of those classes) that inherites from the same parent-class as keys for a WeakMap. The constructors of the 2 derived-classes have a different signature.

So my code looks like this :

class Base{
  constructor(args:{}){}
}

class A extends Base{
  constructor(args:{a:string}){ 
    super(args)
   }
}

class B extends Base{
  constructor(args:{b:number}){ 
    super(args)
   }
}

const myMap:WeakMap<new(args:object)=>Base, number> = new WeakMap([
  // [Base, 0],   I will explain after why there is that commented line.
  [A, 1],
  [B, 2]
])

And the output is :

  No overload matches this call.
  Overload 1 of 2, '(iterable: Iterable<readonly [typeof A, number]>): WeakMap<typeof A, number>', gave the following error.
    Argument of type '([typeof A, number] | [typeof B, number])[]' is not assignable to parameter of type 'Iterable<readonly [typeof A, number]>'.
      The types returned by '[Symbol.iterator]().next(...)' are incompatible between these types.
        Type 'IteratorResult<[typeof A, number] | [typeof B, number], any>' is not assignable to type 'IteratorResult<readonly [typeof A, number], any>'.
          Type 'IteratorYieldResult<[typeof A, number] | [typeof B, number]>' is not assignable to type 'IteratorResult<readonly [typeof A, number], any>'.
            Type 'IteratorYieldResult<[typeof A, number] | [typeof B, number]>' is not assignable to type 'IteratorYieldResult<readonly [typeof A, number]>'.
              Type '[typeof A, number] | [typeof B, number]' is not assignable to type 'readonly [typeof A, number]'.
                Type '[typeof B, number]' is not assignable to type 'readonly [typeof A, number]'.
                  Type at position 0 in source is not compatible with type at position 0 in target.
                    Type 'typeof B' is not assignable to type 'typeof A'.
                      Types of construct signatures are incompatible.
                        Type 'new (args: { a: number; }) => B' is not assignable to type 'new (args: { a: string; }) => A'.
                          Types of parameters 'args' and 'args' are incompatible.
                            Type '{ a: string; }' is not assignable to type '{ a: number; }'.
                              Types of property 'a' are incompatible.
                                Type 'string' is not assignable to type 'number'.
  Overload 2 of 2, '(entries?: readonly (readonly [typeof A, number])[] | null | undefined): WeakMap<typeof A, number>', gave the following error.
    Type 'typeof B' is not assignable to type 'typeof A'.
Argument of type 'typeof A' is not assignable to parameter of type 'new (args: object) => Base'.
  Types of construct signatures are incompatible.
    Type 'new (args: { a: string; }) => A' is not assignable to type 'new (args: object) => Base'.
      Types of parameters 'args' and 'args' are incompatible.
        Property 'a' is missing in type '{}' but required in type '{ a: string; }'.

In my code, I commented a line :

const myMap:WeakMap<new(args:object)=>Base, number> = new WeakMap([
  // [Base, 0],
  [A, 1],
  [B, 2]
])

If I un-comment that line, there is no error. At least there is no error while I do not use my map ^^'. If I call any method of the Map like myMap.get(A), I get an error :

Argument of type 'typeof A' is not assignable to parameter of type 'new (args: object) => Base'.
  Types of construct signatures are incompatible.
    Type 'new (args: { a: string; }) => A' is not assignable to type 'new (args: object) => Base'.
      Types of parameters 'args' and 'args' are incompatible.
        Property 'a' is missing in type '{}' but required in type '{ a: string; }'.

What is the trick ? I mean the class is just used as a key in the map. I do not care about it's signature.

UPDATE : Add a third sub class with a signature even more different :

The member jcalz gave me a solution to fix the issue (read the first comment). It's looks great but I wonder if there is a more generic solution. Indead, that solution (using the never keyword) can be used only if all my sub-classes use a single parameter. But what if I have a class that has a constructor that use more parameters that the others ? So At this point, my code looks like this :

class Base {
  constructor(args: {}) { }
}

class A extends Base {
  constructor(args: { a: string }) {
    super(args)
  }
}

class B extends Base {
  constructor(args: { b: number }) {
    super(args)
  }
}

class C extends Base{
  constructor(args:{}, other:number){
    super(args)
  }
}

Is there any way to do something like this :

const myMap = new WeakMap<any-subClass-of-Base, number>([
  [A, 1],
  [B, 2],
  [C, 3]
])

I know that I can use more generics keywords like :

const myMap = new WeakMap<Function, number>([
  [A, 1],
  [B, 2],
  [C, 3]
])

or just with any :

const myMap = new WeakMap<any, number>([
  [A, 1],
  [B, 2],
  [C, 3]
])

But both completely ignore the fact that I expect to use only as key classes that inherite of Base.


Solution

  • You are running into two problems.


    The first is that the WeakMap constructor, like the Map constructor, has a type that won't let you pass it a heterogeneous array literal. As soon as the key or value types are seen as a union, the compiler rejects it with the error you saw. This is considered a bug in TypeScript, and is filed at microsoft/TypeScript#39133. Until and unless this is fixed, you can work around it by changing constructs like

    const x: Map<K, V> = new Map([[⋯]]);
    

    to

    const x = new Map<K, V>([[⋯]]);
    

    That is, instead of calling the constructor and hoping the compiler will infer the K and V generic type arguments, you manually specify them.


    The next is that new (args: object) => Base does not mean "anything that constructs instances assignable to Base". It specifically means "anything that allows you to call it like new ctor(args) where args is any non-primitive object the caller wants, and which constructs instances assignable to Base. Neither A nor B nor C allow you to call them that way, so you will still get an error.

    Since your use case apparently has nothing to do with actually calling the constructors (at least not when putting it into or getting it out of the map), you don't really care what arguments it accepts. Therefore you're looking for the most permissive constructor type possible. Because of contravariance of function types in their argument types, that means you are looking for the most restrictive parameter types possible. Since the never type is the most restrictive type (nothing is assignable to it), a function whose argument list is of type never is the most permissive function type (every function is assignable to it, at least when it comes to arguments).

    That means you want new (...args: never) => Base and not new (args: object) => Base as the key type for the map.

    Note that if you were to try to enumerate the keys of myMap, you'd get a bunch of values of type new (...args: never) => Base, and that type is essentially impossible to use as a constructor... because the parameter type is so restrictive. But again, you don't plan to do that... as map keys they are just being used as-is, not as constructors.


    Let's test it. If we clear up both problems, we get this:

    const myMap = new WeakMap<new (...args: never) => Base, number>([
      [A, 1],
      [B, 2],
      [C, 3]
    ])
    
    myMap.get(A);
    myMap.get(B);
    myMap.get(C);
    

    That now compiles as desired.

    Playground link to code