Home > Net >  How can I get type narrowing to work here?`
How can I get type narrowing to work here?`

Time:11-12

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.

  • Related