2

When using a generic wrapper around some kind of known state, I ran into incompatible type errors when casting a child to its parent. The idea being that if Tomato extends Fruit, then Wrapper<Tomato> as Wrapper<Fruit> ought to work.

interface Wrapper<TProps> {
    // This next line is correct
    mergeWith<K extends keyof TProps>(merger: (key: K) => any): void;

    update<K extends keyof TProps>(updater: (value: TProps[K]) => any): void;
}

interface BrokenWrapper<TProps> {
    // Somehow this next line seems identical to mergeWith above
    mergeWith(merger: (key: keyof TProps) => any): void;

    update<K extends keyof TProps>(updater: (value: TProps[K]) => any): void;
}

Playground Link

John Jeng
  • 41
  • 3
  • 1
    This is ultimately going to be a [variance](https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)) and [substitution](https://en.wikipedia.org/wiki/Liskov_substitution_principle) issue. I should be able to call `brokenFruit.mergeWith((x: "seeds")=>0)`, where the callback *requires* its parameter be the string literal `"seeds"`. But a value `brokenTomato.mergeWith((x: "seeds")=>0)` is an error; its callback must accept either a `"seeds"` or a `"color"` argument. Since I can't use `brokenTomato` in place of `brokenFruit`, it means `BrokenTomato` is not a `BrokenFruit`. – jcalz Nov 14 '19 at 02:14
  • Thanks for commenting jcalz. I appreciate you naming the category of problem. Regarding the difference between the error code and non-error code, should my conclusion be that using `mergeWith(merger: (key: K)` constrains `key` to be anything that could be a key of TProps (eg. just `"seeds"`) but writing `(key: keyof TProps)` requires it to be exactly `keyof TProps`? ie. `"seeds" | "color"` – John Jeng Nov 14 '19 at 20:23
  • 1
    The generic one is more forgiving in what type of callback it accepts, but it becomes much harder to implement. (After all, what can you do with a callback `merger` of type `(k: K)=>void` for some type `K` that you don't know? Pretty much nothing. You can't call `merger("seeds")` because maybe `merger` only accepts `"color"`. The concrete one is less forgiving in what it accepts: it needs to be a callback that can both handle `"seeds"` and `"color"` as an input. This is much easier to implement. Frankly I think the problem is with your non-"broken" interface. – jcalz Nov 14 '19 at 20:37
  • 1
    Note that in general just because `Y extends X` it doesn't mean that `F extends F`. If you plan to have `F` both read and write values of type `T`, then `F` and `F` are generally unrelated; you can't substitute one for the other at all. So "if `Tomato` extends `Fruit`, then `Wrapper` as `Wrapper` ought to work" will only be true or safe in very specific circumstances. TypeScript does normally allow a lack of safety in a bunch of places, though, so you might still force this relation even if it's unsafe, depending on the use case. – jcalz Nov 14 '19 at 20:42
  • 1
    "Frankly I think the problem is with your non-"broken" interface." Hmm, I see what you mean here. This makes a lot more sense after reading more about [covariance vs contravariance](https://medium.com/@michalskoczylas/covariance-contravariance-and-a-little-bit-of-typescript-2e61f41f6f68). Thanks again for your help! – John Jeng Nov 14 '19 at 20:58

0 Answers0