3
votes

Consider this code:

class Base {}

class Foo<T extends Base> {
  constructor(public callback: (data: T) => void) {
  }
}

let map: Map<number, Foo<Base>> = new Map();

function rpcCall<T extends Base>(
  callback: (data: T) => void,
): void {
  map.set(0, new Foo<T>(callback));
}

It gives me this error:

Argument of type Foo<T> is not assignable to parameter of type Foo<Base>.

Type Base is not assignable to type T.

Base is assignable to the constraint of type T, but T could be instantiated with a different subtype of constraint Base.

I can't see why this shouldn't work. The error message seems correct, but I don't see why that is an error. I want T to be allowed to be a different subtype of constraint Base.

Also, this does work:

class Base { }

class Foo<T extends Base> {
  constructor(public callback: (data: T) => void) {
  }
}

class Foo2<T extends Base> {
}

let map: Map<number, Foo<Base> | Foo2<Base>> = new Map();

function rpcCall<T extends Base>(
  callback: (data: T) => void,

): void {
  map.set(0, new Foo<T>(callback));
}
2
What is your plan for handling this: class BaseWithRaisins extends Base { raisins = 100; } rpcCall((x: BaseWithRaisins) => console.log(x.raisins.toFixed())); map.get(0)!.callback(new Base());? That will throw no compile-time errors, but you will explode at runtime, since a Foo<Base>'s callback must accept any Base, but you gave it something that will only handle a BaseWithRaisins.jcalz
You might want to have map be a Map<number, Foo<any>>, but I don't see what you'll actually be able to do with it. Presumably your use case somewhere involves pulling things out of the map and calling the callbacks, at which point you'll be stuck not knowing what types of argument it accepts. Could you provide a fuller use case with example code for how you expect to use the map?jcalz

2 Answers

2
votes

I'm going to add some properties to your code to illustrate this.

interface Base {
    bar: string;
}

interface Child extends Base {
    magic: number;
}

class Foo<T extends Base> {
    constructor(public callback: (data: T) => void) {
        // '{ bar: string; }' is assignable to the constraint of type 'T',
        // but 'T' could be instantiated with a different subtype of constraint 'Base'.
        callback({ bar: "sweet" });
    }
}

let map: Map<number, Foo<Base>> = new Map();

rpcCall((d: Child) => d.magic);

function rpcCall<T extends Base>(callback: (data: T) => void) {
    // 'Base' is assignable to the constraint of type 'T',
    // but 'T' could be instantiated with a different subtype of constraint 'Base'
    map.set(0, new Foo(callback));
}

Oh dear. More errors!

As you can see, inside Foo I attempted to call the callback you defined, and passed it an object that extends Base, but it threw an error back at me.

What if the callback is expecting a Child? Sure, anything that extends Base is fine as far as Foo cares, but how do you know what the callback itself is expecting?

If you take a look at my usage of rpcCall where I legally gave it a callback expecting a Child, I am trying to use the magic property (which is marked as required in my Child extends Base interface).

Basically at some point there might be an attempt to use something that does not exist on Base.

If you replaced the generics with simply Base would make some of the errors would go away, but doing something like rpcCall((d: Child) => d.magic) would be disallowed. If you don't need non-base properties in these areas, this might be ok for you.


The second version you provided works because Foo2 is an empty class (the generic is completely ignored, in fact, since you don't use it).

An empty class is equivalent to {}, which basically accepts everything except null and undefined (as far as I am aware). And when it comes to union types, any "looser" parameter will take precedence over the more stringent ones.

The below are all equivalent (in this case):

Map<number, Foo<Base> | Foo2<Base>>
Map<number, Foo<Base> | {}>
Map<number, Foo<Foo2<Base>>
Map<number, Foo<{}>

In fact, if you piped | any to the end of any union, that union effectively becomes any.

0
votes

It's a valid error. Under --strictFunctionTypes function type parameter positions are checked contravariantly.

If TS allowed you to pass a function that requires an arbitrary subtype of Base, the Foo constructor would still be able to call it with just a plain Base, and the function expecting a specific subtype would fail.

You can disable --strictFunctionTypes to make your code compile, but you'll lose some type safety, per above. See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html for details.