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

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

Closed
5 tasks done
nth-commit opened this issue Jan 6, 2022 · 16 comments
Labels
Needs More Info The issue still hasn't been fully clarified

Comments

@nth-commit
Copy link

nth-commit commented Jan 6, 2022

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

@webstrand
Copy link
Contributor

webstrand commented Jan 6, 2022

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.

@RyanCavanaugh
Copy link
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.

@RyanCavanaugh RyanCavanaugh added the Needs More Info The issue still hasn't been fully clarified label Jan 12, 2022
@yudai-nkt
Copy link

yudai-nkt commented Feb 3, 2022

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.

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

@RyanCavanaugh
Copy link
Member

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

@yudai-nkt
Copy link

yudai-nkt commented Feb 3, 2022

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

@RyanCavanaugh
Copy link
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>;

@yudai-nkt
Copy link

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.

@Andarist
Copy link
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.

@yudai-nkt
Copy link

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

@theonlypwner
Copy link

theonlypwner commented Jul 13, 2022

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;

@Andarist
Copy link
Contributor

@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

@theonlypwner
Copy link

theonlypwner commented Mar 10, 2023

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}` }>

@theonlypwner
Copy link

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"

@Andarist
Copy link
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.

@theonlypwner
Copy link

theonlypwner commented Mar 14, 2023

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

@Andarist
Copy link
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
Labels
Needs More Info The issue still hasn't been fully clarified
Projects
None yet
Development

No branches or pull requests

6 participants