2

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?

Davis Yoshida
  • 1,757
  • 1
  • 10
  • 24

2 Answers2

2

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);
}
CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
  • This is the sort of solution I was referring to when I said I'd like to avoid explicitly typeguarding the call, since it leads to repetition of the call to applyFunc. In my code that I ran into this issue with I ended up doing that, but I'd prefer to avoid repeating myself. – Davis Yoshida Nov 11 '21 at 23:53
  • @DavisYoshida I believe this is good solution. Union of functions types is a hard type to deal with. Most of the time it is unuseful, because you want an intersection or in other words - overloading. AFAIK, there is no elegat and type safe solution. – captain-yossarian from Ukraine Nov 12 '21 at 08:15
  • 1
    @captain-yossarian I agree it's correct. If the answer is that you _cannot_ accomplish it without duplicating the code for the function call, then I'll accept it as the answer. I'd appreciate a pointer to something in the typescript docs indicating what the limitation is though. – Davis Yoshida Nov 12 '21 at 08:44
2

Generally speaking, if you want to tell Typescript that two variables have correlated types, i.e. "either arg is A and func accepts A, or arg is B and func accepts B", then they need to be properties of some object with a discriminated union type:

{arg: A, func: (x: A) => number} | {arg: B, func: (x: B) => number}

To make it actually work in your code, the discriminated union needs to be built using a distributive conditional type, so the compiler won't complain about the applyFuncWithObject function. I solved this by making ArgAndFunc a parameterised type, so the discriminated union above is ArgAndFunc<A | B>.

type ArgAndFunc<T> = T extends unknown ? {arg: T, func: (x: T) => number} : never

function applyFuncWithObject<T>(obj: ArgAndFunc<T>) {
    // or directly: return obj.func(obj.arg)
    return applyFunc(obj.func, obj.arg);
}

function doTheThing(arg: A | B) {
    let obj: ArgAndFunc<A | B>;
    if(isAnA(arg)) {
        obj = {arg, func: (x: A) => x.a * 2};
    } else {
        obj = {arg, func: (x: B) => parseInt(x.b) * 2};
    }
    return applyFuncWithObject<A | B>(obj);
}

Playground Link

I don't know if this will meet your needs exactly, since it doesn't allow you to pass arg and func as two separate arguments anywhere (without writing a generic helper function like applyFuncWithObject). On the other hand, if you do need to pass arg and func together in multiple places then passing a single object instead will probably simplify your code.

kaya3
  • 47,440
  • 4
  • 68
  • 97
  • This isn't _exactly_ what I was looking for, but it accomplishes the goal without repetition of the execution logic. Thanks! – Davis Yoshida Nov 12 '21 at 19:00