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!
TypeScript - UnionTypeScript allows us to use more than one data type for a variable or a function parameter. This is called union type. Consider the following example of union type. In the above example, variable code is of union type, denoted using (string | number) .
We can differentiate between types in a union with a type guard. A type guard is a conditional check that allows us to differentiate between types. And in this case, a type guard lets us figure out exactly which type we have within the union.
TypeScript allows us to not only create individual types, but combine them to create more powerful use cases and completeness. There's a concept called “Intersection Types” in TypeScript that essentially allows us to combine multiple types.
Types of Functions in TypeScript: There are two types of functions in TypeScript: Named Function. Anonymous Function.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With