-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conditional type is mangling type signature when distributing a union across a function signature #30378
Comments
This is by design. See #21316, specifically: Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation. For example, an instantiation of The workaround is described here: #29535 Link for provenance: #23022 |
Thank you for the reply and for linking to the other issue. I think that despite the design, the actual implementation is leading to broken types (and to a lesser degree, is also unintuitive). For example, this code will throw an error because the resulting function signature will require an argument of type enum E { A, B, C }
type Y<T> = T extends undefined ? undefined : (arg: T) => string;
declare var y: Y<E>;
y(E.A);
// Argument of type 'E.A' is not assignable to parameter of type 'E.A & E.B & E.C'.
// Type 'E.A' is not assignable to type 'E.B'. I understand the purpose of distributing the values on the conditional type, but the behavior of how the type is actually being generated seems incorrect to me? |
The types are not broken, but there is some argument to be made that distributive conditional types can sometimes be unintuitive---at least when you are not expecting them. The intersection type you see is correct. The type declare const fn: ((l: { x: string }) => void) | ((r: { y: boolean }) => void)
// We don't know if fn satisfies the left or right branch, so we must assume both
// when calling the function.
fn({ x: "hello", y: true }); // Input has type { x : string } & { y: boolean } |
Again, thanks for the reply. I appreciate your explanation, but from my personal opinion, if this behavior is expected then it is absolutely not intuitive. The example you provided further convinces me of that - if I were to read the type declaration of this function, I would never expect it to require an argument that has both the left AND right cases. It simply doesn't make sense from a syntactical perspective, nor even from a language perspective (after all, unions are Case A or Case B, not Case A and Case B unless explicitly defined that way, which these types are not). I don't know what else to say on this subject. I certainly understand your point that this is by design. However, I do believe that if this is the case, then maybe the design for this specific case should be revisited. |
No problem!
Just to be clear, are you purely referring to the calling behaviour of unions of functions? I don't want to conflate what the conditional type is doing, and how the resulting union of functions is interpreted. Regarding the conditional type distributing: yes this can be unexpected, though it is neither right or wrong, that is just the design. Having conditional types behave like this be default, and providing a way to turn off distribution, is much easier than the converse. The fact that
The behaviour here is correct in a deeper, semantic perspective: it is the only correct way to implement this (aside from not allowing the function to be called at all). The important thing to emphasise is that functions are contravariant in their argument. That is, everything in the input is negated. So while the functions are in union (or), the inputs are negated, which is an intersection (and). Here is an expanded example of why intersection is correct. const fn: ((l: { x: string }) => string) | ((r: { y: boolean }) => boolean) =
Math.random() ? ((a: { x: string }) => a.x) : ((a: { y: boolean }) => a.y);
// If we could call the input at a union then this would be valid, but its unsound because
// we don't actually know which function we are dealing with. If we picked the left branch
// we would not have an x property!
fn({ y: true });
// We have to do this to make sure we work in all cases!
fn({ x: "hello", y: true }); I hope I didn't conflate or misinterpret any of your points! |
With the deeper explanation and expanded example, I'm beginning to understand the reasoning for the design decision so thank you for taking the time to write all that out. However, I'm still left wondering if the tradeoffs are worth it? While the example does point out why the function arguments need to be intersected here, what about in the case of this type? declare const fn: ((l: string) => string) | ((r: boolean) => number) This becomes a function with this signature, which is essentially a non-callable, useless function: fn(arg0: never): string | number The behavior is entirely dependent on whether a function's arguments are intersectable or not, leading to inconsistent behavior. So it seems to me that distributing function signatures this way is inherently flawed and just leads to a lot of non-intuitive behavior and traps, all the while obfuscating the much more common behavior of using unions within conditional types. Is there a workaround? Yes, but it requires obscure knowledge of putting brackets around the union in order to prevent its distribution 😃. I would also argue, should a union of function signatures even be allowed? Javascript has no easy way to check a function's signature, so you can't guard on a function the same way you would guard on an object by checking if it contains a field (ala Typescript's type guards). So should someone be allowed to write code where it's essentially impossible to understand what function you're going to be calling? Does Typescript want to be supporting such a scenario? This is all becoming pretty philosophical so feel free to end the discussion here. I'm happy to just be bringing up these concerns for possible future design considerations 😄. |
There's zero inconsistency here: The same thing is happening in all cases, it's just that the type
The question here is how you would disallow them. Consider some code type A = { name: "A", f: undefined | ((x: string) => void) };
type B = { name: "B", f: undefined | ((x: number) => void) };
type C = A | B; // Error here?
declare const c: C; // or here?
const f = c.f; // or here?
if (f !== undefined) { // or here?
if (c.name === "A") {
c.f!("");
}
} This code creates an "illegal" type in a bunch of different places, but there is absolutely nothing wrong happening. It's generally wrong for the type system to declare some types completely off-limits, because sometimes those types are just waypoints that eventually do wander back into "legal" territory. |
I should add that not distributing conditional types, as is often suggested, removes about 90% of their key use cases. We should write up a longer reference page on why this all works the way it does. |
Agreed, maybe this was some bad wording. What I meant by "inconsistent" is that sometimes creating a union of function signatures leads to a "valid" type, and sometimes it leads to an "invalid" type (invalid in the sense that it's essentially an uncallable type).
Again agreed, and I'd definitely prefer to have conditional types than not have them. However, the distribution only becomes problematic when distributing across function signatures. And even then, only when distributing unions across function signatures. So it's a pretty specific scenario. I appreciate the explanations and justifications that you guys are laying out. They help explain the implementation much more, and I do think that writing a reference page on all this would be helpful to the community. I'm going to close this for now unless there's a desire to keep discussing this. Thanks again! |
TypeScript Version: 3.4.0-dev.20190312
Code
Expected behavior:
The conditional type (
Y<T>
in the example above) should not be expanding the enum values into an unusable signature.Actual behavior:
The enum's values are getting expanded into multiple signatures, making the type unusable.
Playground Link: Link
The text was updated successfully, but these errors were encountered: