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/
CodePudding user response:
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 a generic:
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
}
}