TypeScript union function types TypeScript union function types typescript typescript

TypeScript union function types


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


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