14

I'm trying to create a union type for arrow functions. The goal would be to be able to infer the type of the second argument based on the first.

Ideally I would like y to be of type number | string, and z to be 'first' | 'second'. But after narrowing down the type of either y or z, infer the narrowed type of the other parameter automatically.

Unfortunately TypeScript doesn't seem to handle a complex case like this, but I was wondering if any of you had ever faced a similar issue.

In a simplified scenario my code looks like:

type Callback<T1, T2> = (y: T1, z: T2) => void;

const test = (x: Callback<number, 'first'> | Callback<string, 'second'>) => {
    return;
}

// Parameter 'y' implicitly has an 'any' type.
// Parameter 'z' implicitly has an 'any' type.
test((y, z) => {
    if(typeof y === 'number') {
        // y is a number
        // so z must be 'first'
    } else {
        // y is a string
        // so z must be 'second'
    }
});

Thanks!

8
  • Do you want test to accept either a Callback<number, 'first'> or a Callback<string, 'second'>? That's how you've typed it, but I doubt that's what you mean since such a callback would be impossible to call safely. Or do you want test to accept a callback which itself can act as either type, meaning it is both of them? That would be more like & instead of |. Commented Oct 30, 2019 at 16:32
  • I want to accept either / or, which why I'm using the |. Commented Oct 30, 2019 at 16:39
  • So, assuming test's implementation didn't just return, what would you hope to do with x? Commented Oct 30, 2019 at 16:40
  • Or, put another way, you need y to be something like number & string the way you've got it, not number | string. That doesn't make much sense, so I'm questioning the typing of x. See this feature; unions of function types can only be called with intersections of parameters. Commented Oct 30, 2019 at 16:42
  • 1
    You can almost do it with type Callback = (...args: [number, 'first'] | [string, 'second']) => void. With this the "implicit any" errors are gone, but the compiler still doesn't know that z must be 'first' if y is a number. Commented Oct 30, 2019 at 16:44

2 Answers 2

23

Here's what's going on as I see it. Let's use these definitions:

type Callback<T1, T2> = (y: T1, z: T2) => void;
type First = Callback<number, 'first'>;
type Second = Callback<string, 'second'>;

First, I'm definitely skeptical that you want a union of functions as opposed to an intersection of functions. Observe that such a union of functions is essentially useless:

const unionTest = (x: First | Second) => {
  // x is *either* a First *or* it is a Second, 
  // *but we don't know which one*.  So how can we ever call it?

  x(1, "first"); // error! 
  // Argument of type '1' is not assignable to parameter of type 'never'.
  x("2", "second"); // error!
  // Argument of type '"2"' is not assignable to parameter of type 'never'.
}

The unionTest() function is the same as your test(), but it can't do anything with x, which is only known to be a First or a Second. If you try to call it you'll get an error no matter what. A union of functions can only safely act on the intersection of their parameters. Some support for this was added in TS3.3, but in this case the parameter types are mutually exclusive, so only acceptable parameters are of type never... so x is uncallable.

I doubt such a union of mutually incompatible functions is ever what anyone wants. The duality of unions and intersections and the contravariance of function types with respect to the types of their parameters are confusing and hard to talk about, but the distinction is important so I feel it's worth belaboring this point. This union is like finding out that I have to schedule a meeting with someone who will either be available on Monday or will be available on Tuesday, but I don't know which. I suppose if I could have the meeting on both Monday and Tuesday that would work, but assuming that doesn't make sense, I'm stuck. The person I'm meeting with is a union, and the day I'm meeting is an intersection. Can't do it.


Instead, what I think you want is an intersection of functions. This is something that corresponds to an overloaded function; you can call it both ways. That looks like this:

const intersectionTest = (x: First & Second) => {
  // x is *both* a First *and* a Second, so we can call it either way:
  x(1, "first"); // okay!
  x("2", "second"); // okay!
  // but not in an illegal way:
  x(1, "second"); // error, as desired
  x("2", "first"); // error, as desired
}

Now we know that x is both a First and a Second. You can see that you can treat it like a First or like a Second and be fine. You can't treat it like some weird hybrid, though, like x(1, "second"), but presumably that's what you want. Now I'm scheduling a meeting with someone who will be available on both Monday and Tuesday. If I ask that person what day to schedule the meeting, she might say "either Monday or Tuesday is fine with me". The day of the meeting is a union, and the person I'm meeting with is an intersection. That works.


So now I'm assuming you're dealing with an intersection of functions. Unfortunately the compiler doesn't automatically synthesize the union of parameter types for you, and you'll still end up with that "implicit any" error.

// unfortunately we still have the implicitAny problem:
intersectionTest((x, y) => { }) // error! x, y implicitly any

You can manually transform the intersection of functions into a single function that acts on a union of parameter types. But with two constrained parameters, the only way to express this is with rest arguments and rest tuples. Here's how we can do it:

const equivalentToIntersectionTest = (
  x: (...[y, z]: Parameters<First> | Parameters<Second>) => void
) => {
  // x is *both* a First *and* a Second, so we can call it either way:
  x(1, "first"); // okay!
  x("2", "second"); // okay!
  // but not in an illegal way:
  x(1, "second"); // error, as desired
  x("2", "first"); // error, as desired
}

That is the same as intersectionTest() in terms of how it behaves, but now the parameters have types that are known and can be contextually typed to something better than any:

equivalentToIntersectionTest((y, z) => {
  // y is string | number
  // z is 'first' | 'second'
  // relationship gone
  if (z === 'first') {
    y.toFixed(); // error!
  }
})

Unfortunately, as you see above, if you implement that callback with (y, z) => {...}, the types of y and z become independent unions. The compiler forgets that they are related to each other. As soon as you treat the parameter list as separate parameters, you lose the correlation. I've seen enough questions that want some solution to this that I filed an issue about it, but for now there's no direct support.

Let's see what happens if we don't immediately separate the parameter list, by spreading the rest parameter into an array and using that:

equivalentToIntersectionTest((...yz) => {
  // yz is [number, "first"] | [string, "second"], relationship preserved!

Okay, that's good. Now yz is still keeping track of the constraints.


The next step here is trying to narrow yz to one or the other leg of the union via a type guard test. The easiest way to do this is if yz is a discriminated union. And it is, but not because of y (or yz[0]). number and string aren't literal types and can't be used directly as a discriminant:

  if (typeof yz[0] === "number") {
    yz[1]; // *still* 'first' | 'second'.  
  }

If you have to check yz[0], you would have to implement your own type guard function to support that. Instead I'll suggest switching on z (or yz[1]), since "first" and "second" are string literals that can be used to discriminate the union:

  if (yz[1] === 'first') {
    // you can only destructure into y and z *after* the test
    const [y, z] = yz;
    y.toFixed(); // okay
    z === "first"; // okay
  } else {
    const [y, z] = yz;
    y.toUpperCase(); // okay
    z === "second"; // okay
  }
});

Notice that after yz[1] has been compared to 'first', the type of yz is no longer a union, and so you can destructure into y and z in a more useful way.


Okay, whew. That's a lot. TL;DR code:

const test = (
  x: (...[y, z]: [number, "first"] | [string, "second"]) => void
) => { }

test((...yz) => {
  if (yz[1] === 'first') {
    const [y, z] = yz;
    y.toFixed();
  } else {
    const [y, z] = yz;
    y.toUpperCase(); // okay
  }
});

Hope that helps; good luck!

Link to code

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for the lengthy explanation, really useful! Happy that you filed an issue, support for this would be really handy. As a side note the TL;DR code is basically the same that i posted, just a tuple args instead of an object. I guess it's handy that way i can keep the arguments separate. :) Thanks again!
wow, thanks for the clarification and the effort - i can see the discussion is still going on three years later (even tho the request has been closed) and it still doesn't work as one would expect in the latest TS tho :/. your solution is perfect until i make one of parameters optional with default value which makes TS confused in infering that param's type
convariance link appears to be down, but there's an archived version: web.archive.org/web/20170724000254/https://www.stephanboyer.com/…
0

Seems like it's not possible to achieve that result with the current tooling of TS, however similar can be achieved if the arguments are supplied as a single object. Although doing a typeof check on y still doesn't narrow the type of z.

type Test<T1, T2> = {
    y: T1;
    z: T2;
};

const test = (x: (args: Test<number, 1> | Test<string, 'second'>) => void) => {
    return;
}

test((args) => {
    if(args.z === 1) {
        // args.y recognized as number
        args.y.toExponential();
    } else {
        // args.y recognized as string
        args.y.split('');
    }
});

3 Comments

Please note that (args: Test<number, 1> | Test<string, 'second'>) => void is not equivalent to ((args: Test<number, 1>)=>void) | ((args: Test<string, 'second'>)=>void). Functions are contravariant in their parameter types, so unions of functions correspond to intersections of function parameters, and vice versa.
I'm aware they are not equivalent, bot for the second scenario TypeScript will only infer an any type. That's why this answer is only the closest I could get, not 1:1 response for the question I had.
Archived article for contravariant provided by jcalz

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.