I'm not getting the result I expected from narrowing on a union type. Here's a snippet which captures the issue:
interface A {
a : number;
}
interface B {
b : string;
}
const isAnA = (arg : A | B) : arg is A => {
return "a" in arg;
}
const applyFunc = <T>(func : (x : T) => number, arg : T) => {
return func(arg)
}
const doTheThing = (arg : A | B) => {
let f;
if (isAnA(arg)) {
f = (x : A) => x.a * 2;
} else {
f = (x : B) => parseInt(x.b) * 2;
}
return applyFunc(f, arg);
}
What I expected to happen here was that the isAnA()
typeguard would let the compiler know that f
has type (x : A) => number
if arg
is an A
, and type (x : B) => number
if arg
is a B
, allowing applyFunc
to be called on arg
and f
together.
However, I get this from the compiler:
Type '(x: B) => number' is not assignable to type '(x: A) => number'.
Types of parameters 'x' and 'x' are incompatible.
Property 'b' is missing in type 'A' but required in type 'B'.
Is there any way to get this working other than explicitly typeguarding the call to applyFunc
?
CodePudding user response:
One thing to do would be to use the conditional operator instead - TypeScript works best when reassignment is minimized. But there's still the problem that you have the type (x: A => number) | (x: B) => number)
is undesirably separated from the type of the argument (A | B
).
I think the best approach here would be to invoke the function needed inside the narrowed condition, to avoid having to re-narrow later, eg:
const doTheThing = (arg: A | B) => {
return isAnA(arg)
? applyFunc((x: A) => x.a * 2, arg)
: applyFunc((x: B) => parseInt(x.b) * 2, arg);
}
CodePudding user response:
Please consider this example:
const doTheThing = (arg: A | B) => {
let f;
if (isAnA(arg)) {
f = (x: A) => x.a * 2;
} else {
f = (x: B) => parseInt(x.b) * 2;
}
// f: ((x: A) => number) | ((x: B) => number)
return applyFunc(f, arg);
}
As you might have noticed, f
has a type of union of two functions: ((x: A) => number) | ((x: B) => number)
.
Let's forget about higher order doTheThing
function and try to call f
out of the scope, like this:
declare let f:((x: A) => number) | ((x: B) => number)
// A & B
f()
f
function expects intersection of A
& B
.
Please see the docs and related answer
Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.
x
argument in both ((x: A) => number) | ((x: B) => number)
is in contravariant position.
See question/answer with simple example of contravariance.
type Covariant<T> = { box: T }
declare let foo: Covariant<{ age: 42 }>
declare let bar: Covariant<{ age: 42, name: 'John' }>
foo = bar
bar = foo // error
type Contravariant<T> = (x: T) => number
declare let foo1: Contravariant<{ age: 42 }>
declare let bar1: Contravariant<{ age: 42, name: 'John' }>
foo1 = bar1 // error
bar1 = foo1
Intuitively, we expect that bar
always assignable to foo
because {age:42, name: 'John'}
is a subtype, but it is not true in second example.
Now, when we know why we have an intersection of A
& B
we can proceed.
Hence, if you want to call applyFunc
only once, you should expect arg
to be an intersection of A
and B
const doTheThing = (arg : A & B) => {
let f;
if (isAnA(arg)) {
f = (x : A) => x.a * 2;
} else {
f = (x : B) => parseInt(x.b) * 2;
}
return applyFunc(f, arg);
}
because this is the only type safe solution. Since f
might be A
or B
TS should be ready for any of them.