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 parameter type based on generic type has inconsistent behavior when generic type passed as generic argument #46155

Closed
Hookyns opened this issue Oct 1, 2021 · 14 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@Hookyns
Copy link

Hookyns commented Oct 1, 2021

Bug Report

🔎 Search Terms

generic conditional parameter type inconsistent behavior

I tried to search a lot of terms but I don't even know how to name this problem.

🕗 Version & Regression Information

  • This is the behavior in every version I tried (I tried all playground versions), and I reviewed the FAQ.

⏯ Playground Link

Playground link with relevant code

💻 Code

class SomeType {}

function getType<T = void>(..._: T extends void ? ["You must provide a type parameter"] : [T?]): SomeType | undefined
{
    return new SomeType();
}

class Bar
{
    getType<T>(t?: T): SomeType | undefined
    {
        return getType<T>(); // Not working here! <----------------------------------
        return getType<T>(t); // Not working either!

        return getType<T>("You must provide a type parameter"); // Not working either! What is T when it does not meet any side of the ternary operator? 
    }
}

const t = undefined;

getType<Bar>(); // Working here!
getType<Bar>(t); // Working here!

getType(); // Correct error!
getType(t);  // Correct error!

🙁 Actual behavior

Call of getType<T>() function with generic parameter as generic argument does not work the same way as call with specific type getType<Bar>().

🙂 Expected behavior

Both cases should work the same way.

@Codesmith1024
Copy link

Codesmith1024 commented Oct 1, 2021

I was gonna report this as a bug, but I feel like this is probably related to your issue --

playground code

// Evaluates to any if T is assignable to U, and never otherwise
type Assignable<T, U> = T extends U ? any : never;
// string | null is apparently assignable to string..?
type A = Assignable<string | null, string>;

// Oh wait, this one properly evaluates to never...
type B = (string | null) extends (string) ? any : never;

Basically, if you hover over A (and actually use it in-code), then A has the type any, implying that string | null can be assigned to type string. B however, evaluates to never, despite doing the exact same check, just without the generic types.

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 1, 2021

@Codesmith1024 They're not doing the same check. A operates on a generic type, B does not. That's a fairly significant difference: Distributive Conditional Types

So basically your type evaluates like this:

  • Assignable<string | null, string> -> becomes distributive
  • (string extends string ? any : never) | (null extends string ? any : never) -> union type is distributed
  • any | never -> both cases evaluated
  • any -> simplified to any

If this behaviour is undesirable in your case, the documentation provides an example on how to opt-out from this feature.

@Codesmith1024
Copy link

Oh gotcha, thanks for the concise link to the docs and an explanation!

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 1, 2021

@Hookyns There's absolutely no way for TypeScript to figure out what T is within your method, both first and second option could be valid, but there's just not enough information to figure it out.

Due to this you're wrongly assuming the parameters of the function are either ..._: ["You must provide a type parameter"] or ..._: [T?], but the parameters are actually ..._: T extends void ? ["You must provide a type parameter"] : [T?], and your argument t does not fulfil this.

@fatcerberus
Copy link

fatcerberus commented Oct 1, 2021

To elaborate on the above: My understanding is, when a conditional type is instantiated with a generic type parameter, its evaluation is deferred and assignability to it is determined by an internal check isTypeIdenticalTo. This is a much stricter check than the usual assignability rules. See my comment here:

#27024 (comment)

This particular check can only be satisfied by another conditional type. T is not a conditional type.

@Hookyns
Copy link
Author

Hookyns commented Oct 4, 2021

@Hookyns There's absolutely no way for TypeScript to figure out what T is within your method, both first and second option could be valid, but there's just not enough information to figure it out.

@MartinJohns Even if I use constraint, so it cannot be void (so false branch should be used), it does not work.
And t is not the point.

Minimal example:

function foo<T = void>(arg: T extends void ? true : false): T | undefined 
{
    return undefined;
}

function callFoo<T extends boolean>()
{
    foo<T>(true); // error, boolean is not assignable...
    foo<T>(false); // same error
}

callFoo<true>();

So @fatcerberus seems be right.
But this behavior is not intended and my code should be compiled without errors, right? Cuz it is valid code IMHO.

Updated example which is a little more complex but closer to my case. Playground

class SomeType 
{
    public readonly ctor: Function;
    constructor(...) { ... }
}

// https://github.com/Hookyns/ts-reflection
function getType<T = void>(..._: T extends void ? ["You must provide a type parameter"] : []): SomeType // <----------
{
    return new SomeType({ ctor: Object, /* and more information */});
}

class ServiceProvider
{
    getService<TDependency extends IService>(t?: SomeType): TDependency // <--- even with constraint; now it cannot be void, never
    {
        t = t ?? getType<TDependency>(); // Not working here! <----------
        return Reflect.construct(t.ctor, []);
    }
}

interface IService 
{
    foo(): void;
}

class SomeService implements IService 
{
    constructor() {}
    foo() {}
}

const sp = new ServiceProvider();

const someService = sp.getService<SomeService>();
someService .foo();

getType<SomeService>(); // Working here! <----------
getType(); // Correct error, because required generic parameter is missing!

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 4, 2021

getType(); // Working here! <----------
getType(); // Correct error, because required generic parameter is missing!

It works here as you expect, because TypeScript knows exactly what type you're dealing with: SomeService and void. As a result TypeScript can resolve the conditional type accordingly.

t = t ?? getType(); // Not working here! <----------

It doesn't work here, because TypeScript doesn't know what type you're having here. TDependency is not an actual type, it's a placeholder for whatever type is passed along. Without knowing the actual type it can't resolve the conditional type. It can't verify whether TDependency extends void, because it's not known what TDependency is.

And constraints are not considered for this. An actual type is needed.

Here's an example to hopefully help you understand the issue:

type Test<T> = T extends number ? true : false

function example1<T extends number>() {
    // The type is neither "true" nor "false", because the provided type argument is generic.
    // As a result "Actual" is still typed "T extends number ? true : false"
    type Actual = Test<T>
}

function example2(value: number) {
    // Here the type argument is not generic, but actually known: number
    // As a result the conditional type can be resolved to "true".
    type Actual = Test<typeof value>
}

@Hookyns
Copy link
Author

Hookyns commented Oct 4, 2021

It doesn't work here, because TypeScript doesn't know what type you're having here. TDependency is not an actual type, it's a placeholder for whatever type is passed along. Without knowing the actual type it can't resolve the conditional type. It can't verify whether TDependency extends void, because it's not known what TDependency is.

Thank You @MartinJohns for explanation what generic means. 😑

And constraints are not considered for this.

So it sounds like TypeScript can and should do that, but TypeScript don't do that. What is the definition of bug, please?

Doesn't matter if it is difficult to implement or not. Is it valid? Should it work? Yes! Or no? Tell me it is a bug and I can invest my time to try to figure it out and maybe make a PR. Just don't say it is working like a charm and it is intended behavior because You don't want to implement it. Just say OK, it is a bug but it will be almost impossible to implement that. Then I will be happy.

And what about this? If TypeScript can report error for const x: T = undefined; it should be able to use the information elsewhere.

function getType<T = undefined>(arg: T extends undefined ? true : false)
{
}

function callFoo<T extends boolean>()
{
    const x: T = undefined; // Error, 'T' is not assignable to type T. Interesting.. TypeScript know what T is, that it cannot be undefined.
    getType<T>(false); // Ohh.. error... it may be undefined.. Sounds like BUG for me.
}

PS: Sorry for my sarcasm, but it seems like You are trying to throw everything, no matter what, just to, idk, hide a bug.

@fatcerberus
Copy link

It’s not a bug. TS can’t resolve the conditional type because it doesn’t know what the type is inside the generic function. The caller has to provide it.

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 4, 2021

function foo<T = void>(arg: T extends void ? true : false): T | undefined 
{
    return undefined;
}

function callFoo<T extends boolean>()
{
    foo<T>(true); // error
    foo<T>(false); // hypothetical universe where this doesn't error
}

callFoo<never>() // ok; never extends boolean

foo<never>(false) // this errors because never extends void
                  // note that this didn't error in the `callFoo` invocation

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 4, 2021

Sorry for my sarcasm, but it seems like You are trying to throw everything, no matter what, just to, idk, hide a bug.

I'm not part of the TypeScript team, and I'm not trying to hide anything. I was merely trying to explain you why it's not a bug and you're mistaken. But at some point I have to accept that I can't explain it any more simple, so I'll excuse myself from this issue.

@m-rutter
Copy link

m-rutter commented Oct 4, 2021

@Hookyns It feels like you are expecting modus tollens to apply to the evaluation of the conditional types. If the value level argument is the literal false, then the type level expression T extends undefined must be false, which makes some kind of sense and probably is intuitive if you come from a propositional logic background.

However, this just isn't how conditional types are evaluated, changing this would be a very signficant breaking change, and changing this would introduce signficant structure comparsion checks that would create an performance overhead that would make the conditional type feature unusable.

Effectively conditional types only support modus ponens.

@Hookyns
Copy link
Author

Hookyns commented Oct 6, 2021

@Hookyns It feels like you are expecting modus tollens to apply to the evaluation of the conditional types. If the value level argument is the literal false, then the type level expression T extends undefined must be false, which makes some kind of sense and probably is intuitive if you come from a propositional logic background.

However, this just isn't how conditional types are evaluated, changing this would be a very signficant breaking change, and changing this would introduce signficant structure comparsion checks that would create an performance overhead that would make the conditional type feature unusable.

Effectively conditional types only support modus ponens.

Thank You @m-rutter , this makes more sense.

I was expecting that ternary operator in conditional types works like ternary operators, which is (A => B) or (!A => C), but it seemed like there is some type D and nothing implies to it.

I will check the implementation (see you next year 😄), because my idea of implementation (that false can be used as an argument in foo call) was something like:

// callExpression is foo<T>(false)
isTypeIdenticalTo(
    getType(callExpression.arguments[0]) /* "false" */, 
    getType(callExpression.declarations[0].parameters[0]) /* T extends undefined ? true : false */, 
    callExpression.typeArguments /* T extends boolean, to let the isTypeIdenticalTo function know what T is */
)

And the 3rd argument of isTypeIdenticalTo is what I am missing. Everyone says that TypeScript doesn't know what T is,.. so why not to tell it? Maybe it's just my lack of knowledge how the typechecking works.

@fatcerberus
Copy link

Everyone says that TypeScript doesn't know what T is

To be fair, it would be more accurate to say that the evaluation of the conditional type is deferred, because actually evaluating such things in a generic context requires higher-level reasoning than the compiler is capable of. Basically, we know that the code inside the body of foo must work for any valid instantiation of T - parametricity - literally an infinite number of types, as opposed to one single type that you know upfront. In a few cases the compiler can--and will--pretend the type parameter is equal to its constraint, but in general this causes more problems than it solves due to variance concerns. Anyway, while a conditional type is "deferred" in this manner, the compiler doesn't reason about its branches individually - it sees a single atomic thing that it can only do an identity check on. Hence the behavior you see here.

And as @m-rutter pointed out, TS doesn't do modus tollens inferences. That kind of reasoning would require negated types - see #29317. The compiler can do inferences of the form (T => U) & T => U all day long, but it pretty much never does (T => U) & ~U => ~T, because it has no way to represent ~T internally.

@sandersn sandersn added Working as Intended The behavior described is the intended behavior; this is not a bug Design Limitation Constraints of the existing architecture prevent this from being fixed and removed Working as Intended The behavior described is the intended behavior; this is not a bug labels Oct 7, 2021
@sandersn sandersn closed this as completed Oct 7, 2021
@sandersn sandersn added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Design Limitation Constraints of the existing architecture prevent this from being fixed labels Oct 7, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

7 participants