2

I have interface defined as below:

type Shape = | Triangle | Rectangle;
interface Triangle {...}
interface Rectangle {...}

function foobar(func: (shape: Shape) => void) {...}

function test() {
    const fb = (rect: Rectangle) => {...};
    foobar(fb); // giving me error: argument of type a is not assignable to parameter of type b...
}

but if I do something like below, it is working fine:

function computeArea(shape: Shape) {
  .....
}


const triangle: Triangle = {...};
const rect: Rectangle = {...}

computeArea(triangle);

I am wondering why type Rectangle is compatible with Shape in method computeArea, whereas it is not compatible with Shape in method test. And what's best practice to solve the issue in method test?

I added the mini repro code here: https://playcode.io/934375/

flyingbee
  • 601
  • 1
  • 7
  • 17
  • Please provide a self-contained [mre] that clearly demonstrates the issue you are facing. Ideally I could paste such code into a standalone IDE and immediately get to work solving the problem without first needing to re-create it. In particular, pseudocode ellipses should be replaced with a minimal amount of actual viable code. – jcalz Aug 02 '22 at 02:02
  • But, assuming I get the point of the question, it's because functions are *contravariant* in their parameter types. Every rectangle is a shape, but not vice versa. And every function that accepts shapes is a function that accepts rectangles, but not vice versa. If I ask for food, and you give me a sandwich, I'll be happy. But if I ask you for a food waste receptacle and you give me something that only accepts *sandwiches*, I'd be confused and unhappy. That's the issue you're running into. As for "best practice to solve" it, it really depends on the use case, and for that I need more info. – jcalz Aug 02 '22 at 02:14
  • If the above makes sense and if you can update your question to be a [mre], I'd be happy to write up a full answer. If not, let me know what doesn't work for you. – jcalz Aug 02 '22 at 02:14
  • @jcalz Thanks! I added the mini repro example here: https://playcode.io/934375, can you please check? The error is in the line of getShapeArea(circle, computeCircleArea);, I am wondering what is the best practice for this logic. – flyingbee Aug 02 '22 at 04:06
  • The existing answer is likely correct to the "why is this happening" question. I could add something about generics to answer the "what is the best practice" question, like [this](https://tsplay.dev/NdA5Qm). But a Stack Overflow question post is supposed to ask a single question. Which one of those is your main concern: "why this error", or "how to fix"? Whichever it is, please [edit] the post to be clear which question is primary so that there is a clear criterion for acceptance. – jcalz Aug 02 '22 at 13:37
  • Also, please note that the [mre] should be in plain text in the body of the question itself. An external link is a nice supplement, but isn't sufficient by itself. – jcalz Aug 02 '22 at 13:37
  • Thanks for your answers again as well as the advice on the minimal reproducible examples. It's very useful! I noticed that in StackOverflow we can only run JavsScript code but not TS code, so I added the external link which is runnable :-) – flyingbee Aug 02 '22 at 17:31

1 Answers1

1

As designed in your example, Rectangle is a member of Shape union.

The easily understandable situation is for the computeArea function, which can handle any Shape, hence calling it with a Rectangle is fine.

But, counter-intuitively, it is the opposite for foobar function, which expects a callback handling a Shape, but calling it with a callback that takes a Rectangle gives an error...

As we say, callback arguments are contravariant, i.e. here should foobar expect a callback handling a Rectangle, we could have done foobar(computeArea).

It may be easier to understand why it does not work in the question example, if we imagine that foobar internally builds an arbitrary Shape (which could then be a Triangle), and tries to process it with the given callback: fb would not be appropriate in that case (because it cannot handle the Triangle, only a Rectangle).

Whereas if foobar said it needed a callback handling a Rectangle, it would have meant that internally it would execute that callback only with a Rectangle argument. Hence a callback that can handle any Shape (therefore including a Rectangle), like computeArea, would have been perfectly fine.


In your reproduction code, if I understand correctly, the foobar function is actually:

function getShapeArea(shape: Shape, computeArea: (shape: Shape) => number) {
  const area = computeArea(shape);
  return area;
}

And TypeScript gives an error when we try to call it with:

getShapeArea(circle, computeCircleArea);

...where circle is a Circle (also a member of Shape union), and computeCircleArea a function that takes a Circle and returns a number.

Here the transpiled JavaScript code runs fine at runtime (despite the TS error message).

But if the callback argument was covariant, we could have also used the function as:

getShapeArea(triangle, computeCircleArea);

...in which case the code would have very probably thrown an Exception (the triangle having no radius).

If we had a computeArea callback that worked for any Shape, then it would work (illustrating the contravariance of the callback argument), because whatever the actual shape 1st argument of getShapeArea function, it could be processed by that hypothetical computeArea callback.

In your precise case, you might want to give TS more hints that the 2 arguments of getShapeArea are related (the callback just need to handle the same shape, not an arbitrary shape). Typically with generics:

function getShapeArea<S extends Shape>(shape: S, computeArea: (shape: S) => number) {
  const area = computeArea(shape);
  return area;
}

With this, getShapeArea(circle, computeCircleArea) is no longer a TS error!

And getShapeArea(triangle, computeCircleArea) is more obviously a mistake (because computeCircleArea cannot handle the triangle).


Depending on your exact situation, you may even further improve getShapeArea function by automatically detecting the shape and using the appropriate compute function accordingly (without having to specify it everytime as a 2nd argument), using type narrowing:

function getShapeArea(shape: Shape) {
  // Using the "in" operator narrowing
  // https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-in-operator-narrowing
  if ("radius" in shape) {
    // If "Circle" type is the only "Shape" type with a "radius" property,
    // then TS automatically guesses that "shape" is a Circle
    return computeCircleArea(shape); // Okay because TS now knows that "shape" is a Circle
  } else if ("width" in shape && "length" in shape) {
    // Similarly, if "Rectangle" is the only "Shape" with both "width" and "length",
    // then TS guesses that shape is a Rectangle
    return computeRectangleArea(shape); // Okay because TS now knows that "shape" is a Rectangle
  }
}
ghybs
  • 47,565
  • 6
  • 74
  • 99
  • Thanks for the detailed answer! One thing I don't quite understand is the foobar method, so it is expecting "Shape", instead of Rectangle right? And we are passing Rectangle (fb) to a Shape type (foobar). Since Shape is either Rectangle or Triangle, why it cannot take Rectangle? Sorry I think my 1st line is wrong, it should be 'type Shape = | Rectangle | Triangle. I also added the code here that can run: https://playcode.io/934375 – flyingbee Aug 02 '22 at 04:09
  • As described above, the situation may be counter-intuitive for callback arguments: they are _contravariant_. Here the issue is that we are _not_ passing a Rectangle instead of a Shape, but trying to pass a _callback_ handling "only" a Rectangle instead of a cb that should be able to handle _any_ Shape – ghybs Aug 02 '22 at 11:00
  • I added specific details of your repro code in above answer. – ghybs Aug 02 '22 at 11:23
  • Thanks for your details answer and explanation! So, in my understanding, the "" approach basically relates those two 'Shape' arguments in getShareArea together, so that they could only have the same Type (either Triangle or Circle), hence avoiding the issue. So, "Triangle" or "Circle" can be treated as an interface that is extended from Shape, right? – flyingbee Aug 02 '22 at 17:42
  • My additional understanding is when we do "extends" in , we are creating a specific class, so we are using either Triangle or Circle consistently, whereas Shape could be one type at a place and could be another type at another place. – flyingbee Aug 02 '22 at 17:52
  • | function getShapeArea(shape: Shape), And thanks for this suggestion! I will consider switching to this approach too. – flyingbee Aug 02 '22 at 19:31
  • 1
    You can see it like that indeed. "_when we do "extends" [...], we are creating a specific class_": to be more accurate, the _use of generics_ gives a specific type; the "extends" part _constrains_ that generics (so that it is at least a `Shape`, otherwise it could be a string, boolean, etc.). See https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints – ghybs Aug 03 '22 at 01:58