Skip to content
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

Closed
ylibrach opened this issue Mar 13, 2019 · 9 comments
Labels
Duplicate An existing issue was already created

Comments

@ylibrach
Copy link

TypeScript Version: 3.4.0-dev.20190312

Code

enum E {
    A,
    B,
    C
}

// Declaring a standard function alias type against the enum works fine:
type X<T> = (arg: T) => string;
declare var x: X<E>; // CORRECT: X<E>

// Declaring a conditional alias type against the enum returns an unusable type:
type Y<T> = T extends undefined ? undefined : (arg: T) => string;
declare var y: Y<E>; // <-- INCORRECT: ((arg: E.A) => string) | ((arg: E.B) => string) | ((arg: E.C) => string)

// For some reason, one-tupling the enum type returns a correct type:
type Z<T> = [T] extends undefined ? undefined : (arg: T) => string;
declare var z: Z<E>; // <-- CORRECT: (arg: E) => string

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

@jack-williams
Copy link
Collaborator

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 T extends U ? X : Y with the type argument A | B | C for T is resolved as (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y).

The workaround is described here: #29535

Link for provenance: #23022

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Mar 13, 2019
@ylibrach
Copy link
Author

ylibrach commented Mar 14, 2019

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 E.A & E.B & E.C instead of E.A | E.B | E.C:

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?

@jack-williams
Copy link
Collaborator

jack-williams commented Mar 14, 2019

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 Y<E> produces a union of function types because the conditional type distributes over each member of the enum. When calling y, a function of union type, a call signature is synthesised by the checker. See here for the details. To summarise though, calling a function of union type requires that the argument satisfies every argument type from the union (this is because we don't know precisely which part of the union the function satisfies, so must conservatively assume all of them. Satisfying every argument type is the same as satisfying the intersection of the argument types. An example.

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 }

@ylibrach
Copy link
Author

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.

@jack-williams
Copy link
Collaborator

Again, thanks for the reply.

No problem!

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).

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 y has a union of functions as its type is simply a consequence of the conditional type, and this is the expected result.

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).

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!

@ylibrach
Copy link
Author

ylibrach commented Mar 14, 2019

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 😄.

@RyanCavanaugh
Copy link
Member

The behavior is entirely dependent on whether a function's arguments are intersectable or not, leading to inconsistent behavior.

There's zero inconsistency here: The same thing is happening in all cases, it's just that the type string & boolean resolves to never because zero values inhabit that set.

I would also argue, should a union of function signatures even be allowed?

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.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Mar 15, 2019

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.

@ylibrach ylibrach changed the title Conditional type is mangling type signature when using an enum Conditional type is mangling type signature when distributing a union across a function signature Mar 15, 2019
@ylibrach
Copy link
Author

There's zero inconsistency here: The same thing is happening in all cases, it's just that the type string & boolean resolves to never because zero values inhabit that set.

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).

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.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

3 participants