Davis Yoshida
Davis Yoshida

Reputation: 1785

How can I get type narrowing to work here?`

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?

Upvotes: 2

Views: 367

Answers (2)

kaya3
kaya3

Reputation: 51043

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.

Upvotes: 2

CertainPerformance
CertainPerformance

Reputation: 370769

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);
}

Upvotes: 2

Related Questions