2

I have the following code example:

class Stupid {
  private cache: Map<any, any> = new Map();
  get<T>(key: string): T {
    return this.cache.get(key);
  };
}

class Smart<T> extends Stupid {
  get(key: string): T {
    super.get<T>(key);
  }
}

I have valid reasons for wrapping the Stupid class (other than the generics) which are not apparent in this barebone reproduction of the problem, nonetheless I would like to know if this is somehow possible.

Please also note that the class Stupid is a node dependency. I can't change that implementation.

The purpose would be to not have a generic type argument on each method, but to move it to the wrapping class (class Smart) and use that in super calls, to provide the required generic to the extended class (class Stupid).

Please feel free to use this playground for experimentation.

  • This is not possible because `Stupid.get()` says it returns any type the *caller* wants, while `Smart.get()` can only return the type `T` the *implementer* chose. Those are mismatches so what you're asking for is inconsistent. Indeed it's essentially impossible to implement `(key: string)=>T` safely so it's not surprising you'd have an issue. You'll need a type assertion or the equivalent somewhere if you want to do this, perhaps as shown [in this playground link](//tsplay.dev/WJV4ZW). Does that fully address the question? If so I'll write up an answer; if not, what am I missing? – jcalz Mar 31 '23 at 16:44
  • As far as I can tell it does answer the question, so thank you very much. If you do write up an answer, please explain in more detail what you're doing there with the Smarter interface and the FakeSmart implementation. I'm not sure i understand the code example entirely. – Andrei Bereczki Mar 31 '23 at 17:17

1 Answers1

0

The get() method in Stupid has the problematic generic call signature <T>(key: string) => T, which means that it will return a value of any type T that the caller specifies. This is demonstrably impossible:

const stupid = new Stupid();
const n = stupid.get<number>("xyz");
// const n: number
const s = stupid.get<string>("xyz");
// const s: string

In the above, you're calling stupid.get("xyz") twice at runtime, but the compiler thinks the first call returns a number and the second one returns a string, which is exceptionally unlikely. The only reason the method implementation type checks is because the return type of cache.get() is the intentionally unsafe any type.

Let's not worry too much about the type safety of Stupid.get() and instead look at the problem you're having when subclassing it:


class Smart<T> extends Stupid {
  get(key: string): T { // error!
    return super.get<T>(key);
  }
}

const smart: Smart<number> = new Smart();
const n = smart.get("xyz");
// const n: number

The reason that doesn't work is because subclasses must be assignable to their superclasses. If Smart<T> extends Stupid, then every Smart<T> instance is also a Stupid instance, and should be usable accordingly:

const stupid: Stupid = smart; // this should be allowed
const s = stupid.get<string>("xyz"); 
// const s: string // uh oh

Smart<number>.get() returns a number, but Stupid.get() returns any type the caller wants, such as string. These are not compatible behaviors, and so the compiler complains. If you want Smart to truly be a subclass of Stupid, you'd need to make get() generic the same way as it is in Stupid, which is not what you're trying to do.


So how can we proceed? Presumably we shouldn't worry too much about type safety, since there's no way to guarantee that get() returns the proper type for either Stupid or Smart. Instead we will just do what's necessary to make it compile. Usually that will require something like a type assertion to tell the compiler that some value is of some type.

Here's one way to do it:

interface Smart<T> {
  get(key: string): T
}

const FakeSmart = Stupid as new <T>() => ISmart<T>;

class Smart<T> extends FakeSmart<T> {
  get(key: string): T {
    return super.get(key);
  }
}

What we're doing is pretending that Stupid behaves like Smart. First we create an ISmart<T> interface which is the same as Stupid except that the generic is moved to where you want it. Then we assign the Stupid constructor to a new variable called FakeSmart and assert that FakeSmart has the type of a generic constructor. So FakeSmart is just Stupid at runtime, but the compiler thinks it is of a proper superclass of Smart<T>.

And then we declare that Smart<T> extends FakeSmart<T>. At runtime this is that same as your original code, but now at compile time there are no errors because the get() method of Smart<T> is compatible with the get() method of FakeSmart<T>. Note that the call super.get(key) doesn't take a type argument because FakeSmart.get() doesn't take a type argument.


So there you go. Personally I'd be worried about code like this where you can tell the compiler simultaneous contradictory things about the types, but if you're not the one writing Stupid then I guess the best you can do is wrap it in something more reasonable.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you for the details explanation. I think at this point (since i mostly have to redeclare all methods Stupid potentially has) I'm better off declaring a private property in Smart with a new Instance of Stupid and drop the "extends Stupid". Basically just wrap Stupid entirely. The resulting code would be more maintainable than the typing gymnastics you proposed, which, don't get me wrong ... it's an elegant solution given the question, but like you said, when you get to write such code, it starts to beg the question ... should i really? :) What do you think @jcalz? – Andrei Bereczki Mar 31 '23 at 18:24
  • I agree. You *might* be able to programmatically generate the `Smart` type from `Stupid`, but yeah, dropping `extends` could be much easier, especially if you were going to override each method anyway; `this.stupid` isn't much harder to write than `super` anyway. – jcalz Mar 31 '23 at 19:58