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

Add 'extends' clause to 'infer' type #48112

Merged
merged 7 commits into from
Apr 5, 2022
Merged

Add 'extends' clause to 'infer' type #48112

merged 7 commits into from
Apr 5, 2022

Conversation

rbuckton
Copy link
Member

@rbuckton rbuckton commented Mar 3, 2022

This adds an optional extends clause to infer T to specify an explicit constraint for the T type parameter. When specified,
the explicit constraint overrides the constraint we would have attempted to infer for T.

For example, if you wished to infer a type from the first element of a tuple but also constrain that type, you might currently need to write something like the following:

// This type alias serves as an example of any type with a constraint you might need to satisfy
type MustBeNumber<T extends number> = { y: T };

// Option 1, use a nested conditional
type X1<T> =
  T extends [infer U] ?
    U extends number ? MustBeNumber<U> :
    never :
  never;

// Option 2, use a secondary type to constrain the inference
type Is<T extends U, U> = T;
type X2<T> =
  T extends [Is<infer U, number>] ? MustBeNumber<U> :
  never;

For Option 1, you are forced to create two conditional types, resulting in two alternatives. If you are testing multiple other conditions, this could result in an unmanageable branching structure which results in the need to define additional type aliases for repeated branches:

type X1<T> =
  T extends [infer U] ? U extends number ? MustBeNumber<U> : 
    T extends {x: infer U} ? U extends number ? MustBeNumber<U> :
      ...
  // repeats...
  T extends {x: infer U} ? U extends number ? MustBeNumber<U> :
    ...

For Option 2, you are required to define a second type to enforce the constraint. While this is trivial, it is unreliable when inferring to the same type variable in more than one position, as it is possible to define disjoint constraints:

type X2<T> = T extends [Is<infer U, number>, Is<infer U, string>] ? MustBeNumber<U> : never; // U will always be `never`

In contrast, specifying an explicit constraint via extends allows us to achieve the simpler branching structure of Option 2, while providing additional safety by checking that the constraints are consistent:

// simpler branching due to combining the constraint
type X3<T> = T extends [infer U extends number] ? MustBeNumber<U> : never;

// all constraints for same type parameter must match, if present
type X4<T> = T extends [infer U extends number, infer U extends number] ? MustBeNumber<U> : never;

// only one type parameter needs to be constrained (similar to class/interface merging)
type X5<T> = T extends [infer U extends number, infer U] ? MustBeNumber<U> : never;
type X6<T> = T extends [infer U, infer U extends number] ? MustBeNumber<U> : never;

// errors on inconsistent constraints
type X7<T> = T extends [infer U extends string, infer U extends number] ? U : never;
// error: All declarations of 'U' must have identical constraints.

Related #48094

@RyanCavanaugh
Copy link
Member

Do we have a sense of what kind of libraries would benefit from this?

@rbuckton
Copy link
Member Author

rbuckton commented Mar 4, 2022

Probably anyone doing tuple type or template literal type manipulation, plus #48112. I regularly find myself needing to define something like Is or use Extract when inferring from tuple types when I need a more specific type than inference allows.

I found a few places that could leverage this:

@phryneas
Copy link

phryneas commented Mar 4, 2022

You can add the Redux ecosystem (Redux Toolkit, Reselect) to that list - we have a few cases where we have a
Something<X> extends infer Y ? Y extends Bar ? /* actual logic */.

I'm pretty sure @tannerlinsley will have quite a few uses in React Table for this, but that's speculation from my side.

@tannerlinsley
Copy link

I would greatly benefit from this in React Table and React Location where I am already writing a lot branching conditional types to both enforce and extract generics. It’s brittle. This would make those contracts easier to define and much more reliable.

@DanielRosenwasser
Copy link
Member

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 4, 2022

Heya @DanielRosenwasser, I've started to run the tarball bundle task on this PR at bafe193. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 4, 2022

Hey @DanielRosenwasser, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/121221/artifacts?artifactName=tgz&fileId=9A84662ABF9C9E378556EF91C66AB21BF7E1E2513A39ED6E4C61DFA8C11CBA6302&fileName=/typescript-4.7.0-insiders.20220304.tgz"
    }
}

and then running npm install.


There is also a playground for this build and an npm module you can use via "typescript": "npm:@typescript-deploys/pr-build@4.7.0-pr-48112-6".;

@rbuckton
Copy link
Member Author

rbuckton commented Mar 7, 2022

In the design meeting we discussed the syntax ambiguity with infer T extends U ?. There are three approaches we can take:

  1. Leave the precedence as written in this PR. This would be a breaking change since the following is perfectly legal TS today:

    type X<T> = T extends (infer U extends number ? 1 : 0) ? 1 : 0;

    If we choose this option, the above would become a parse error and existing code would need to change to parenthesize the infer U.

  2. Use speculative parsing disambiguate infer T extends U ?. This means that we would speculatively parse the extends clause of an infer type, and if we encounter a ? we would rewind. This is similar to how we speculatively parse arrow functions, but has the same drawback: If the type in the extends clause is very large we would have to re-parse it again. This may be acceptable as infer T extends U ? doesn't seem to be a common occurrence in the wild, and if a project does encounter a slowdown due to the lookahead, they can easily wrap the infer type in parens to avoid rewinding.

  3. Use a cover grammar to disambiguate infer T extends U ?. This means that we would parse the infer T extends U as an infer type, and if we encounter a ? following that type we would recreate the infer type without the constraint and reuse the node for the constraint. This avoids the performance implications of speculative parsing, but will require more thorough testing to ensure that it doesn't break incremental parsing scenarios.

  4. Use a different keyword instead of extends for an infer type's constraint, such as of (i.e., infer T of U). This avoids the ambiguity, but introduces a second term with the same meaning as extends, which could be a source of confusion.

I investigated using something like JS's [In] parser context, however that approach didn't seem viable. The places we would want to allow extends in an infer type (such as in a parenthesized type) are the same places you might want to infer to the check type of a conditional type, so there was no way to separate them using a context flag.

@rbuckton
Copy link
Member Author

rbuckton commented Mar 7, 2022

You can add the Redux ecosystem (Redux Toolkit, Reselect) to that list - we have a few cases where we have a Something<X> extends infer Y ? Y extends Bar ? /* actual logic */.

[...]

I have been a bit wary of classifying all cases of ... infer Y ? Y extends Bar ? ... as possible use cases since Y extends Bar ? ... could also indicate the need to distribute over Y rather than merely constraining it. Switching to infer T extends U will definitely need to be assessed on a case-by-case basis.

@phryneas
Copy link

phryneas commented Mar 7, 2022

You can add the Redux ecosystem (Redux Toolkit, Reselect) to that list - we have a few cases where we have a Something<X> extends infer Y ? Y extends Bar ? /* actual logic */.
[...]

I have been a bit wary of classifying all cases of ... infer Y ? Y extends Bar ? ... as possible use cases since Y extends Bar ? ... could also indicate the need to distribute over Y rather than merely constraining it. Switching to infer T extends U will definitely need to be assessed on a case-by-case basis.

Of course, but we only really use it in cases where we have the else case be never - we only really add that inner extends to make the compiler happy. Might not be the core of this PR, but it definitely helps us write code.

@RyanCavanaugh
Copy link
Member

I'd also toss in "require parenthesization" as a possible parsing strategy

type X<T> = T extends (infer U extends number) ? 1 : 0;
@rbuckton
Copy link
Member Author

rbuckton commented Mar 9, 2022

I'd also toss in "require parenthesization" as a possible parsing strategy

type X<T> = T extends (infer U extends number) ? 1 : 0;

Unfortunately, requiring parens alone doesn't resolve the ambiguity, since T extends (infer U extends number ? 1 : 0) ? 1 : 0 is already legal (and thus we could still encounter a trailing ?).

Right now I'm leaning towards (2) for its simplicity, but changing the noConditionalTypes parameter of parseTypeWorker into a parser context flag so that we can relax the need for parens in situations where we can be sure a trailing ? wouldn't be ambiguous:

// ok, parsed as conditional
type X1<T> = T extends ((infer U) extends number ? 1 : 0) ? 1 : 0; 

// ok, parsed as `infer..extends` (speculative parse succeeds due to no trailing `?`)
type X2<T> = T extends (infer U extends number) ? 1 : 0; 

// ok, parsed as `infer..extends` (conditional types not allowed in 'extends type' of conditional)
type X3<T> = T extends infer U extends number ? 1 : 0; 

// ok, parsed as `infer..extends` (precedence wouldn't have parsed the `?` as part of a type operator)
type X4<T> = T extends keyof infer U extends number ? 1 : 0; 

// ok, parsed as conditional (speculative parse rewinds when it sees the first `?`)
type X5<T> = T extends { [P in infer U extends keyof T ? 1 : 0]: 1; } ? 1 : 0; 

// ok, parsed as `infer..extends` (no trailing `?`)
type X6<T> = T extends { [P in infer U extends keyof T]: 1; } ? 1 : 0; 

// ok, parsed as conditional (speculative parse rewinds when it sees the first `?`)
type X7<T> = T extends { [P in keyof T as infer U extends P ? 1 : 0]: 1; } ? 1 : 0; 

// ok, parsed as `infer..extends` (speculative parse succeeds due to no trailing `?`)
type X8<T> = T extends { [P in keyof T as infer U extends P]: 1; } ? 1 : 0; 

It doesn't work quite the same way as [In], since this still requires speculative parsing, but its reliable and consistent and isn't a breaking change.

@treybrisbane
Copy link

I'm gonna go ahead and ask the dumb question: Why can't we automatically derive the constraint of an inferred type binding from the constraint of the type being matched?

For example, given

type Quote<AString extends string> = `"${AString}"`;

type QuotedFirstElement<AStringTuple extends readonly string[]> =
  AStringTuple extends readonly [infer FirstElement, ...infer Rest]
    ? Quote<FirstElement> // Currently errors here, because `FirstElement` is only constrained to `unknown`
    : never;

why can't we say "well, since AStringTuple has a constraint of readonly string[], any elements we infer from it should therefore have a constraint of string"?

Intuitively, I suspect there's a very good reason for this and that I just haven't thought it through enough, but someone's gotta spring the trap! 😁

@rbuckton
Copy link
Member Author

rbuckton commented Mar 9, 2022

I'm gonna go ahead and ask the dumb question: Why can't we automatically derive the constraint of an inferred type binding from the constraint of the type being matched?

We do have automatic inference, and there are ways we can improve it. However, that still wouldn't solve the issue at hand. There are times where you need a more-specific type than what the inferred constraint might allow:

type X<T extends any[]> =
  T extends [infer U extends string, ...infer R] ? ... :
  T extends [infer U extends number, ...infer R] ? ... :
  ...;

Above, I might want to make branching decisions based on a more specific constraint. This also ties into #48094, which uses more specific inferences for infer in a template literal type to pull out numeric literals.

@sandersn sandersn added this to Not started in PR Backlog via automation Mar 11, 2022
@sandersn sandersn moved this from Not started to Waiting on reviewers in PR Backlog Mar 11, 2022
@rbuckton
Copy link
Member Author

This last change modifies the parse based on #48112 (comment).

I wanted to ensure that parenthesization was accurate, so I took a pass through the parenthesizer rule logic for types to be more accurate. One side-effect of that is that we'll emit fewer parens in declarations and diagnostics. Where we might have previously written (A & B) | (C & D), we may now write A & B | C & D. The parens weren't strictly necessary, but they may have made the diagnostics more readable. I can make union/intersection parenthesization more aggressive in general, if necessary, or possibly just pass a more aggressive parenthesizer to the printer for typeToString if we only want it for diagnostics/quickinfo.

@DanielRosenwasser, can you provide your thoughts on the change in diagnostics evidenced here: 38eb412?show-viewed-files=true&file-filters%5B%5D=#diff-daa0fded8957f3b2eecdd68e6f89377663f965eb6e9c2b83f36955200b6e858fL5-R6

@rbuckton
Copy link
Member Author

@typescript-bot perf test
@typescript-bot run dt
@typescript-bot test this
@typescript-bot user test this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 11, 2022

Heya @rbuckton, I've started to run the extended test suite on this PR at 2fb093c. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 11, 2022

Heya @rbuckton, I've started to run the parallelized Definitely Typed test suite on this PR at 2fb093c. You can monitor the build here.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 15, 2022

Heya @rbuckton, I've started to run the parallelized Definitely Typed test suite on this PR at 326389b. You can monitor the build here.

@rbuckton
Copy link
Member Author

PR Backlog automation moved this from Waiting on reviewers to Needs merge Mar 23, 2022
Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parser changes look good but I can't adequately comment on the checker changes.

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeToTypeNodeHelper in the checker needs to include the constraint in the context.inferTypeParameters case of type.flags & TypeFlags.TypeParameter. If it's not there, we'll elide the constraints in error messages and inferred type declaration emit (eg, if one of these is inlined into a function return type).

Other than that, looks good - pretty simple.

PR Backlog automation moved this from Needs merge to Waiting on author Mar 23, 2022
@rbuckton
Copy link
Member Author

rbuckton commented Mar 24, 2022

typeToTypeNodeHelper in the checker needs to include the constraint in the context.inferTypeParameters case of type.flags & TypeFlags.TypeParameter. If it's not there, we'll elide the constraints in error messages and inferred type declaration emit (eg, if one of these is inlined into a function return type).

I think the latest change should cover this. I reuse getInferredTypeParameterConstraint, and elide the extends clause if the constraint is something we would have inferred from the surrounding context. Constraints inferred from a type argument to a type reference are preserved since the type reference may have been elided during typeToTypeNode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
10 participants