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

Generic conditional type prematurely resolves to one of the branches instead of deferring #57650

Open
jcalz opened this issue Mar 5, 2024 · 6 comments
Assignees
Labels
Needs Investigation This issue needs a team member to investigate its status.

Comments

@jcalz
Copy link
Contributor

jcalz commented Mar 5, 2024

🔎 Search Terms

generic, conditional, premature / eager / defer, inline,

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about generic conditional types

⏯ Playground Link

Playground link

💻 Code

type IsNumber<T> = [T] extends [number] ? true : false
type IsFalse<T extends boolean> = false[] extends T[] ? true : false

type Direct = IsFalse<IsNumber<string>> extends true ? 0 : 1;
//   ^? type Direct = 0

type Shortcut<T> = IsFalse<IsNumber<T>> extends true ? 0 : 1;
type Indirect = Shortcut<string>;
//   ^? type Indirect = 1

🙁 Actual behavior

Direct and Indirect are different.

🙂 Expected behavior

Direct and Indirect should be the same because Direct is just an inlined version of Shortcut<string>.

Additional information about the issue

This feels like a similar issue to #39364, #30708, #30020 and maybe some others, but many of those have been resolved already and yet this one remains. Came from this Stack Overflow question.

@fatcerberus
Copy link

I feel like there was a recent issue about exactly this...

@Andarist
Copy link
Contributor

Andarist commented Mar 5, 2024

A somewhat smaller repro would be this:

type IsNumber<T> = [T] extends [number] ? true : false
type Shortcut<T> = false[] extends IsNumber<T>[] ? 0 : 1;
//   ^? type Shortcut<T> = 1

TS tries to check if the check type is assignable to a permissive instantiation of the extends type. That permissive instantiation is true[] here so it ends up checking false[] extends true[] ? 0 : 1 and it determined that this can never be true and picked up the false type as the result.

Some extra mechanisms that avoid over-eager simplifications were introduced in #56004 but that was only when computing constraints of conditional types and in this situation forConstraint (the variable introduced there) is false.

This is the most relevant piece of the implementation: link. Perhaps the biggest surprise (for me) is that this extends type (IsNumber<T>[]) is not deferred (it depends on a type variable after all).

The definition of isDeferredType is pretty simple:

    function isDeferredType(type: Type, checkTuples: boolean) {
        return isGenericType(type) || checkTuples && isTupleType(type) && some(getElementTypes(type), isGenericType);
    }

I have never formed a good mental model for what a generic type is internally (again, this one depends on a type variable but it's "just" a type reference to Array and this is not called a generic type internally, even if it has generic type arguments).

@fatcerberus
Copy link

fatcerberus commented Mar 5, 2024

That's weird that the so-called "permissive instantiation" of IsNumber<T> would be true[]; I'd expect it to be boolean[] (or maybe never[], i.e. intersection of true and false types)1 since true and false are disjoint and there's seemingly no reason to prefer the true branch of the conditional over the false branch. Of course we'd have the same problem in the end, since the eager evaluation itself is the problem here, but c'est la vie.

Footnotes

  1. I'm interpreting "permissive" here to mean "compatible with whatever ultimately comes out of the conditional type", which of course depends on things like variance, how the true and false types are related, etc.

@rotu
Copy link

rotu commented Mar 5, 2024

Especially weird to me that this works again if I define IsFalse with tuple types instead of array types.

type IsFalse2<T extends boolean> = [false] extends [T] ? true : false
type Shortcut2<T> = IsFalse2<IsNumber<T>> extends true ? 0 : 1;
type Indirect2 = Shortcut2<string>;
//   ^? type Indirect2 = 0

https://www.typescriptlang.org/play?jsx=0&ts=5.3.3#code/C4TwDgpgBAkgzgOQK4FsBGEBOAeAKgPigF4oBtXAXSggA9gIA7AEzjIdQ0yoH4phMk0AFxQAZgEMANnAgAoUJFhwAYlJl5qdRiyhoA9nskRxDQiQnSIpKrXrNWua1F79BUERZmz54aABEAS0wIAGNgYiVVS2x4ZHQsbDh+AIYAc3xCW21WV2heAAZ3KABGAG5ZAHoKqBqAPW5vBWgAZQALPUxgEKRgPDNItQgYxA4Egkytez4BPKhCkTKfRRhmINDwkjaOrp7E5LT8cqqaqHrG3wHLACYNLKn9Q2NTCNJPCBtJnXIeabcPQaWLXanW6wBuBAi8CiMhusVGOHGmjsOlyzjmRUWTVgq2CYSuES2IJ6NySmBS6SO1TqDSAA

@Andarist
Copy link
Contributor

Andarist commented Mar 5, 2024

That's weird that the so-called "permissive instantiation" of IsNumber would be true[]

A permissive instantiation is an instantiation that maps type parameters to a wildcard type (an internal type of any). But even with a regular any we get this:

type IsNumber<T> = [T] extends [number] ? true : false
type Result = IsNumber<any>
//   ^? type Result = true

Especially weird to me that this works again if I define IsFalse with tuple types instead of array types.

To quote the source code:

// When the check and extends types are simple tuple types of the same arity, we defer resolution of the
// conditional type when any tuple elements are generic. This is such that non-distributable conditional
// types can be written `[X] extends [Y] ? ...` and be deferred similarly to `X extends Y ? ...`.
const checkTuples = isSimpleTupleType(checkTypeNode) && isSimpleTupleType(extendsTypeNode) &&
    length((checkTypeNode as TupleTypeNode).elements) === length((extendsTypeNode as TupleTypeNode).elements);

I don't know why tuples are special this way. I understand that wrapping types in tuples might be a common way to make your conditional types non-distributive but I don't think I've ever seen this to be documented as the only way to do that. My personally mental model was always that you can make conditional types non-distributive by simply making check type "non-naked" and T[] is something that I'd call non-naked.

But as I alluded to in my previous comment - the problem is not exclusive to arrays, likely just any other type reference (other than a tuple) with generic type parameters exhibits the same problem:

type IsNumber<T> = [T] extends [number] ? true : false;
type Shortcut<T> = Record<string, false> extends Record<string, IsNumber<T>> ? 0 : 1;
//   ^? type Shortcut<T> = 1

@fatcerberus
Copy link

@Andarist I believe it’s been stated by a maintainer (@ahejlsberg, perhaps?) that, while the documented behavior is “naked type parameters are distributive”, there’s special logic to handle the pattern [T] extends [Foo] ? …, for perf reasons I believe, because that’s the most common way to write non-distributive conditionals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Investigation This issue needs a team member to investigate its status.
Projects
None yet
Development

No branches or pull requests

6 participants