Skip to content

Allowing infer after extends T in conditional types would simplify complicated type definitions massively #47330

Description

Suggestion

🔍 Search Terms

  • distributed conditional types
  • infer keyword

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Doing this would allow the complete removal of an entire branch, and it's much more intuitive (the hanging infer stumped me a whole lot when I was learning).

Before:

InferringType<T> = T extends infer U ? U extends SomeConcreteType ? U : never : never

After:

InferringType<T> = T extends SomeConcreteType infer U ? U : never

Alternatives

  1. Something consistent with the as keyword in template string literal types e.g.
InferringType<T> = T extends SomeConcreteType as U ? U : never
  1. No extra syntax, just smart control flow analysis on the type variable.
InferringType<T> = T extends SomeConcreteType
  ? T // T extends SomeConcreteType on this branch
  : never // T does not extend SomeConcreteType on this branch

📃 Motivating Example

This playground link - has some documentation that should provide some context for the following code, but this is a pretty universal pattern anyway.

Before:

type InferEventFromTypeMatch<TypeMatch extends string> = TypeMatch extends infer InferredTypeMatch
  ? InferredTypeMatch extends Event['type']
    ? Extract<Event, AbstractEvent<InferredTypeMatch, any>>
    : InferredTypeMatch extends `${infer WildcardedTypeMatch}*`
    ? Event extends infer EventCase
      ? EventCase extends { type: `${WildcardedTypeMatch}${any}` }
        ? EventCase
        : never
      : never
    : never
  : never

After:

type InferEventFromTypeMatch0<TypeMatchString extends string> =
  TypeMatchString extends Event['type'] infer ExactTypeMatchString // Can give a much more meaningful label
    ? Extract<Event, AbstractEvent<ExactTypeMatchString, any>>
    : InferredTypeMatch extends `${infer WildcardedTypeMatch}*`
      ? Event extends { type: `${WildcardedTypeMatch}${any}` } infer WildcardTypeMatch // Can give a much more meaningful label here too
        ? WildcardTypeMatch 
        : never
      : never // Only two hanging nevers, rather than four

💻 Use Cases

Activity

  1. webstrand commented on Jan 6, 2022

    @webstrand
    Contributor

    I think what you're looking for already exists, with a slightly different syntax:

    type Is<T extends U, U> = T

    Translating your second version directly:

    type Is<T extends U, U> = T;
    
    type InferEventFromTypeMatch<TypeMatchString extends string> =
      TypeMatchString extends Is<infer ExactTypeMatchString, Event['type']>  // Can give a much more meaningful label
        ? Extract<Event, AbstractEvent<ExactTypeMatchString, any>>
        : TypeMatchString extends `${infer WildcardedTypeMatch}*`
          ? Event extends Is<infer WildcardTypeMatch, { type: `${WildcardedTypeMatch}${any}` }>  // Can give a much more meaningful label here too
            ? WildcardTypeMatch 
            : never
          : never // Only two hanging nevers, rather than four

    But in your specific example you don't need it at all:

    type InferEventFromTypeMatch<TypeMatchString extends string> =
    TypeMatchString extends Event['type']  // Can give a much more meaningful label
      ? Extract<Event, AbstractEvent<TypeMatchString, any>>
      : TypeMatchString extends `${infer WildcardedTypeMatch}*`
        ? Event extends { type: `${WildcardedTypeMatch}${any}` }  // Can give a much more meaningful label here too
          ? Event 
          : never
        : never // Only two hanging nevers, rather than four

    There's a cost to inference, so in idiomatic code we do not use it to rename types. Narrowing a type with T extends Something and then using T is not hard to follow, once you get used to it.

  2. RyanCavanaugh commented on Jan 12, 2022

    @RyanCavanaugh
    Member

    This is already how it works?

    type Testable = { test(): void };
    type GetTest<T extends Testable> = { m: T };
    // Nothing needed here to match the constraint
    type Foo<T> = T extends Testable ? GetTest<T> : never;

    A more straightforward motivating example would be helpful here.

  3. yudai-nkt commented on Feb 3, 2022

    @yudai-nkt

    tl;dr I need this feature as well but for a different reason. This feature can help us avoid reaching the recursion depth limit.

    I came across this issue while searching for a duplicate of what I was going to submit.

    Narrowing a type with T extends Something and then using T is not hard to follow, once you get used to it.

    I agree with this. Renaming type parameters won't affect readability much once you learn the syntax of conditional type. That said, I still find this feature useful to mitigate the compiler's recursion depth limit. Let me illustrate the problem I'm having right now.

    In the example below (playground), DottedKey receives one object type parameter and resolves to a union of all the possible property access chained with a dot.

    type DottedKey<T, K extends keyof T = keyof T> = K extends string
      ? T[K] extends Record<string, any>
        ? T[K] extends infer V
          ? V extends (infer E)[]
            ? K | `${K}.${number | "length"}` | `${K}.${number}.${DottedKey<E>}`
            : K | `${K}.${DottedKey<V>}`
          : never
        : K
      : never;
    
    // "foo" | "foo.bar"
    type T0 = DottedKey<{foo: {bar: string}}>
    
    // "foo" | `foo.${number}` | "foo.length" | `foo.${number}.bar`
    type T1 = DottedKey<{foo: {bar: string}[]}>
    
    // "foo" | "foo.bar" | "foo.baz"
    type T2 = DottedKey<{foo: {bar: string} | {baz: number}}>

    While this works as expected with simple types, giving complex types yields a compile error TS2589: Type instantiation is excessively deep and possibly infinite.. I want to use DottedKey with the constituents of @types/estree's Node, and they are complex enough to cause the error.

    I've confirmed that removing at least one conditional type in DottedKey fixes the error in my use case, but they are all required including the seemingly redundant T[K] extends infer V, which enables union distribution. So I can't go that way.

    I thought the following patch could be a workaround, but it didn't help. Unwanted literals such as foo.bar.charAt become assignable to T0. This comes from the fact that [T[K], T[K]] extends [Record<string, any>, infer V] somehow evaluates to true when T is {bar: string} (reproduction). I haven't figured out why yet.

    @@ -1,9 +1,7 @@
     type DottedKey<T, K extends keyof T = keyof T> = K extends string
    -  ? T[K] extends Record<string, any>
    -    ? T[K] extends infer V
    +  ? [T[K], T[K]] extends [Record<string, any>, infer V]
           ? V extends (infer E)[]
             ? K | `${K}.${number | "length"}` | `${K}.${number}.${DottedKey<E>}`
             : K | `${K}.${DottedKey<V>}`
    -      : never
         : K

    That's why I need this feature. If something like T[K] extends Records<string, any> infer V (open to better syntax ideas) is available, recursion depth won't hit its limit without breaking the definition of DottedKey.

    Ryan Cavanaugh (@RyanCavanaugh) Is this straightforward and motivating? Please let me know if there is any room for further elaboration.

  4. RyanCavanaugh commented on Feb 3, 2022

    @RyanCavanaugh
    Member

    Yudai Nakata (@yudai-nkt) I think this a misunderstanding - almost certainly, we wouldn't change the distributivity behavior under this construct

  5. yudai-nkt commented on Feb 3, 2022

    @yudai-nkt

    Ryan Cavanaugh (@RyanCavanaugh) I don't want the distributivity behavior to be changed. Let me rephrase my issue and possible solutions.

    My original DottedKey implementation has 4 nested conditional types and I want to reduce the number to 3 or less. My workaround in the previous comment would have reduced it to 3, but it is not working unfortunately.

    type DottedKey<T, K extends keyof T = keyof T> = K extends string
      ? [T[K], T[K]] extends [Record<string, any>, infer V]
        ? V extends (infer E)[]
          ? K | `${K}.${number | "length"}` | `${K}.${number}.${DottedKey<E>}`
          : K | `${K}.${DottedKey<V>}`
        : K
      : never;

    The feature requested by the issue author will also reduce the number of conditional types. [T[K], T[K]] extends [Record<string, any>, infer V] will be replaced with something like T[K] extends Record<string, any> infer V.

    In both cases, the nest depth is reduced to 3 and I still have a type parameter V, which can be distributive in a conditional type (unlike T[K]).

  6. RyanCavanaugh commented on Feb 3, 2022

    @RyanCavanaugh
    Member

    Sure, but nothing is going to make DottedKey not fail on at least some types.

    type M = { next: M | { data: number } };
    type F = DottedKey<M>;
  7. yudai-nkt commented on Feb 4, 2022

    @yudai-nkt

    Yes, but M doesn't need to be covered by DottedKey for my specific use case. So I don't really care at the moment.

  8. Andarist commented on Feb 25, 2022

    @Andarist
    Contributor

    q: is there any info anywhere on what T extends infer R ? R : never does and how it affects other parts of the inference algorithm? Intuitively I've assumed that it's forcing the compiler to resolve T based on the information it was able to gather.

    I've assumed that in a situation like this it wouldn't have any effect on how typechecking works for the argument that should be typechecked with the outcome of ComputeStuff:

    interface TypegenDisabled { "@@xstate/typegen": false; }
    interface TypegenEnabled { "@@xstate/typegen": true; }
    
    type TypegenConstraint = TypegenEnabled | TypegenDisabled;
    
    type ComputeStuff<TTypesMeta> = TTypesMeta & { /* do stuff */ };
    
    declare function createMachine<
      TTypesMeta extends TypegenConstraint = TypegenDisabled
    >(
      config: { tsTypes?: TTypesMeta },
      implementations: TTypesMeta extends infer EvaluatedTypesMeta
        ? ComputeStuff<EvaluatedTypesMeta>
        : never
    ): void;

    Unfortunately, it changes the behavior "down the road" but since tsTypes is an optional property I also need to "resolve" it quickly to TypegenDisabled case, and while this trick works for that, some other test cases of mine break due to the mentioned change of behavior.

    This is just an example to illustrate what I'm doing but I'm really after "fixing" it - I would like to better understand how I could influence the order in which TS infers stuff. Basically, I know where the information I need is in the input arguments but I'm not sure how to best control what should be "deferred" by TS. Note that I'm aware of NoInfer, LowInfer, and other stuff but this is still just a magic box that I find hard to manage in a complex scenario.

  9. yudai-nkt commented on Apr 29, 2022

    @yudai-nkt

    I'm trying 4.7 beta and it looks like #48112 fixes this issue although recursion limit is not relaxed yet.

  10. theonlypwner commented on Jul 13, 2022

    @theonlypwner

    I'm trying to understand the motivation behind this issue and #48112.

    Considering the first post of this issue, what's the difference between U extends SomeConcreteType ? U : never and U & SomeConcreteType?

    Before:

    type InferringType<T> = T extends infer U ? U extends SomeConcreteType ? U : never : never

    After:

    type InferringType<T> = T extends infer U ? (U & SomeConcreteType) : never

    And in the Motivating Example, isn't it equivalent to this?

    type InferEventFromTypeMatch<TypeMatch extends string> =
      TypeMatch extends Event['type']
        ? Extract<Event, AbstractEvent<TypeMatch, any>>
        : TypeMatch extends `${infer WildcardedTypeMatch}*`
          ? Event & { type: `${WildcardedTypeMatch}${any}` }
          : never

    The announcement for #48112 has an example of 3 equivalent declarations, the last one of which is added by the pull request:

    type FirstIfString<T> =
        T extends [infer S, ...unknown[]]
            ? S extends string ? S : never
            : never;
    type FirstIfString<T> =
        T extends [string, ...unknown[]]
            // Grab the first type out of `T`
            ? T[0]
            : never;
    type FirstIfString<T> =
        T extends [infer S extends string, ...unknown[]]
            ? S
            : never;

    Isn't that also equivalent to this?

    type FirstIfString<T> =
        T extends [infer S, ...unknown[]]
            ? (S & string)
            : never;

    I realize the pull request is useful for cases like this:

    type StringBox<T extends string> = {x:T};
    
    type FirstIfString<T> =
        T extends [infer S, ...unknown[]]
            ? StringBox<S & string>
            : never;
    
    type FirstIfString2<T> =
        T extends [infer S extends string, ...unknown[]]
            ? StringBox<S>
            : never;
    
    type FirstIfString2Alt<T> =
        T extends [infer S, ...unknown[]]
            ? S extends string
                ? StringBox<S>
                : never
            : never;
    
    type D = FirstIfString<[boolean, number, string]>; // { x: never }
    type D2 = FirstIfString2<[boolean, number, string]>; // never
    type D3 = FirstIfString2Alt<[boolean, number, string]>; // never

    They really should have put a better example in the announcement.


    Update: actually, isn't this even simpler?

    type StringBox<T extends string> = {x:T};
    
    type FirstIfString<T> =
        T extends any[]
            ? T[0] extends string
                ? StringBox<T[0]> // T[0] in original example
                : never
            : never;
  11. Andarist commented on Mar 10, 2023

    @Andarist
    Contributor

    Ryan Cavanaugh (@RyanCavanaugh) I think this is basically solved by the mentioned #48112 - the only difference is in the allowed order between infer and extra extends. The OP's example can be rewritten (using the added capability) as follows:

    type InferEventFromTypeMatch<TypeMatchString extends string> =
      TypeMatchString extends infer ExactTypeMatchString extends Event["type"]
        ? Extract<Event, AbstractEvent<ExactTypeMatchString, any>>
        : TypeMatchString extends `${infer WildcardedTypeMatch}/*`
        ? Extract<Event, AbstractEvent<`${WildcardedTypeMatch}/${any}`, any>>
        : never;

    TS playground with the full thing

  12. theonlypwner commented on Mar 10, 2023

    @theonlypwner
    type InferEventFromTypeMatch<TypeMatchString extends string> =
      TypeMatchString extends infer ExactTypeMatchString extends Event["type"]
        ? Extract<Event, AbstractEvent<ExactTypeMatchString, any>>
        : TypeMatchString extends `${infer WildcardedTypeMatch}/*`
        ? Extract<Event, AbstractEvent<`${WildcardedTypeMatch}/${any}`, any>>
        : never;

    How is this different from my example?

    type InferEventFromTypeMatch<TypeMatch extends string> =
      TypeMatch extends Event['type']
        ? Extract<Event, AbstractEvent<TypeMatch, any>>
        : TypeMatch extends `${infer WildcardedTypeMatch}*`
          ? Event & { type: `${WildcardedTypeMatch}${any}` }
          : never

    Also, OP's example doesn't have the extra / characters in the strings.


    I tried rewriting OP's example, and I ended up with

    type InferEventFromTypeMatch<TypeMatch extends string> =
      TypeMatch extends Event['type']
        ? Extract<Event, AbstractEvent<TypeMatch, any>>
        : TypeMatch extends `${infer WildcardedTypeMatch}*`
          ? Extract<Event, { type: `${WildcardedTypeMatch}${any}` }>
          : never

    This is the same as my past version, except

    Event & { type: `${WildcardedTypeMatch}${any}` }

    and

    Extract<Event, { type: `${WildcardedTypeMatch}${any}` }>
  13. theonlypwner commented on Mar 10, 2023

    @theonlypwner

    Sure, but nothing is going to make DottedKey not fail on at least some types.

    type M = { next: M | { data: number } };
    type F = DottedKey<M>;

    It seems to work if we rewrite to an alternate DottedKeyA: (playground)

    type DottedKey<T, K extends keyof T = keyof T> = K extends string
      ? T[K] extends Record<string, any>
        ? T[K] extends infer V
          ? V extends (infer E)[]
            ? K | `${K}.${number | "length"}` | `${K}.${number}.${DottedKey<E>}`
            : K | `${K}.${DottedKey<V>}`
          : never
        : K
      : never;
    
    type DottedKeyA<T, K extends keyof T = keyof T> = K extends string
      ? T[K] extends Record<string, any>
        ? T[K] extends (infer E)[]
          ? K | `${K}.${number | "length"}` | `${K}.${number}.${DottedKeyA<E>}`
          : K | `${K}.${DottedKeyA<T[K]>}`
        : K
      : never;
    
    
    type M = { next: M | { data: number } };
    type F = DottedKey<M>; // Type instantiation is excessively deep and possibly infinite.(2589)
    type FA = DottedKeyA<M>; // "next"
  14. Andarist commented on Mar 11, 2023

    @Andarist
    Contributor

    How is this different from my example?

    Sorry, I didn't notice your post. Either way, there are different ways to write this type - the point is (also mentioned by you) that infer+extends combination (almost like proposed by the OP) is already available.

  15. theonlypwner commented on Mar 14, 2023

    @theonlypwner

    #48112 resolves the issue in the original post, but perhaps the recursion limit issue mentioned in the comments should be resolved (perhaps this issue should be closed as complete, and the recursion limit should be opened as a new issue?).

    As OP's motivating example can be rewritten without the new feature infer A extends B, which I mentioned in my comments before I realized that @webstrand's earlier comment had already mentioned it, I don't feel it is a good motivating example. There are better examples in this comment at #48112.

  16. Andarist commented on Mar 14, 2023

    @Andarist
    Contributor

    I think that the recursion limit issue is mostly mitigated by the ability to write tail recursive conditional types:

    type JoinPath<A extends string, B extends string> = [A] extends [never]
      ? B
      : [B] extends [never]
      ? A
      : `${A}.${B}`;
    
    type _DottedKey<
      T,
      Path extends string = never,
      R extends string = never
    > = T extends readonly (infer E)[]
      ? _DottedKey<
          E,
          JoinPath<Path, `${number}`>,
          R | JoinPath<Path, `${number | "length"}`>
        >
      : T extends Record<string, unknown>
      ? keyof T extends infer K extends string
        ? _DottedKey<T[K], JoinPath<Path, K>, R | JoinPath<Path, K>>
        : never
      : R;
    
    type DottedKey<T> = _DottedKey<T>;

    TS playground

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs More InfoThe issue still hasn't been fully clarified

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions