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

Comparing constrained generic types/substitution types to conditional types #23132

Open
mhegazy opened this issue Apr 4, 2018 · 20 comments
Open
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@mhegazy
Copy link
Contributor

mhegazy commented Apr 4, 2018

When comparing a generic type to a conditional type whose checkType is the same type, we have additional information that we are not utilizing.. for instance:

function f<T extends number>(x: T) {
    var y: T extends number ? number : string;

    // `T` is not assignable to `T extends number ? number : string`
    y = x;
 }

Ignoring intersections, we should be able to take the true branch all the time based on the constraint.

The intuition here is that T in the example above is really T extends number ? T : never which is assignable to T extends number ? number : string.

Similarly, with substitution types, we have additional information that we can leverage, e.g.:

declare function isNumber(a: any): a is number;

function map<T extends number | string>(
    o: T,
    map: (value: T extends number ? number : string) => any
): any {
    if (isNumber(o)) {
        // `T & number` is not assignable to `T extends number ? number : string`
        return map(o);
    }
}
@jack-williams
Copy link
Collaborator

jack-williams commented Apr 4, 2018

@mhegazy
In the second example: is the reason that T & number should be assignable to T extends number ? number : string because the bound of the conditional is number | string, and the intersection includes number?[1] Or is it due to some combination of the type-variable and the intersection?

[1] Counter example: pick T to be never. The intersection needs to include T.

@mhegazy
Copy link
Contributor Author

mhegazy commented Apr 4, 2018

That is the type that results narrowing the type of o to number. it becomes T & number. which should behave, as i noted in the OP, as T extends number ? T : never, which is assignable to T extends number ? number : string

@jack-williams
Copy link
Collaborator

jack-williams commented Apr 4, 2018

Sorry I misunderstood. I thought that 'behaving as T extends number ? T : never' only referred to the top case when T extends number, and that perhaps there was a subtle difference with the intersection.

As in the top case the constraint applies to the type T, but in the bottom case the evidence only applies to the value, the constraint for T in general cannot be narrowed.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed In Discussion Not yet reached consensus labels Apr 17, 2018
@RyanCavanaugh
Copy link
Member

Looking for concrete examples of where this comes up more legitimately

@kevinbeal
Copy link

@RyanCavanaugh I think I have one.

interface ControlResult<T> {
	fields: ConvertPrimitiveTo<T, FormControl>;
	controls: T extends any[] ? FormArray : FormGroup;
}

From #23803

I think @mhegazy 's alternative solution there may work though.

@mattmccutchen
Copy link
Contributor

Two semi-legitimate examples from #25883:

  • Existential types
  • The "generic index" workaround for mapped types that always substitute on indexing

@Andarist
Copy link
Contributor

Andarist commented Mar 2, 2019

My legitimate (I think 😉 ) use case:

Having a mapped type which tests against undefined with conditional type, can't perform that with constrained generic - see playground

@MicahZoltu
Copy link
Contributor

My use case is that I want to have a generic base class that that does full discriminated union checking based on the generic type and then dispatches to an abstract method in a fully-discriminated way. This makes it so I can write a very simple/trim handler class which is valuable when you have 100s of things to discriminate between. In this particular case, there are multiple discriminations along the way that need to "aggregate" into a fully narrowed type, which is why we run into this bug.

Note: ClientHandler can further be generalized into something like the following which would allow us to use the same discriminating base class for both Client and Server handlers by just including the direction in the class declaration. I chose to leave out this generalization in this issue to avoid making things too clouded.

Handler<T extends Message, U extends { direction: 'request' | 'response' }>

Note: While it may not appear so at first glance, the error received here reduces down to this bug. After deleting code until I had nothing left but the simplest repro case I ended up with #32591, which appears to be a duplicate of this.

Note: The excessive usage of types here is to ensure that we get type checking and it is really hard to create a new request/response pair without properly implementing everything. The goal is to make it so a developer showing up to the project can create a new class CherryHandler extends ClientHandler<CherryMessage> and then get compile errors until they have done all of the work necessary (including creating all of the necessary types) to make that handler work properly.

interface BaseChannel { channel: Message['channel'] }
interface BaseRequest { direction: 'request' }
interface BaseResponse { direction: 'response' }
type RequestMessage = Extract<Message, BaseRequest>
type ResponseMessage = Extract<Message, BaseResponse>
type Message = AppleMessage | BananaMessage

const isRequestMessage = (maybe: Message): maybe is RequestMessage => maybe.direction === 'request'
const isResponseMessage = (maybe: Message): maybe is ResponseMessage => maybe.direction === 'response'

abstract class ClientHandler<T extends Message> {
    receive = (message: Message) => {
        if (!isResponseMessage(message)) return
        if (!this.isT(message)) return
        // Type 'AppleResponse & T' is not assignable to type 'Extract<T, AppleResponse>'.
        this.onMessage(message) // error
    }

    abstract onMessage: (message: Extract<T, ResponseMessage>) => void
    abstract channel: T['channel']

    private readonly isT = (maybe: Message): maybe is T => maybe.channel === this.channel
}

interface AppleChannel extends BaseChannel { channel: 'apple' }
interface AppleRequest extends BaseRequest, AppleChannel {  }
interface AppleResponse extends BaseResponse, AppleChannel { }
type AppleMessage = AppleRequest | AppleResponse
class AppleHandler extends ClientHandler<AppleMessage> {
    // we'll get a type error here if we put anything other than 'apple'
    channel = 'apple' as const

    // notice that we get an AppleResponse here, because we already fully discriminated in the base class
    onMessage = (message: AppleResponse): void => {
        // TODO: handle AppleResponse
    }
}

interface BananaChannel extends BaseChannel { channel: 'banana' }
interface BananaRequest extends BaseRequest, BananaChannel {  }
interface BananaResponse extends BaseResponse, BananaChannel { }
type BananaMessage = BananaRequest | BananaResponse
class BananaHandler extends ClientHandler<BananaMessage> {
    channel = 'banana' as const
    onMessage = (message: BananaResponse): void => { }
}

@MicahZoltu
Copy link
Contributor

This issue keeps biting me over and over in this project. I wish I could thumbs-up once for each time I suffer from the fact that {a:any} is a valid generic instantiation of T extends {a:'a'}.

Latest is basically this (greatly simplified):

function fun<T extends Union>(kind: T['kind']) {
    const union: Union = { kind } // Type '{ kind: T["kind"]; }' is not assignable to type 'Union'.
}
fun<{kind: any}>({kind: 5}) // it is crazy to me that this line is valid

I want to be able to tell the compiler, "any should be treated as unknown and is not a valid extension of string (or anything else)".

@michaeljota
Copy link

@RyanCavanaugh another legit example of this, I'm creating a rxjs operator that returns an instance of a value if the stream is not an array, or an array of instances if it is. I have to use any in the array branch to make it work. (Observable types were removed, but the main use case, and the problem, still remains)

Playground

@Gianthra
Copy link

Gianthra commented Aug 19, 2019

I'm having the same issues with this and what might help is the below:

function GetThing<T>(default:T, value:unknown): T | void {
    if(typeof default === "boolean") return !!value; // Type 'boolean' is not assignable to type 'void | T'. ts(2322)
}
// OR
function GetThing<T>(default:T, value:unknown): T {
    if(typeof default === "boolean") return !!value;
    // Type 'boolean' is not assignable to type 'T'.
    //  'boolean' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{}'. ts(2322)

    // etc.
}

Those just simplify the reproduction.
However one of the more interesting ones might be this one:

function GetThing<T>(default:T, value:unknown): T {
	if(typeof default === "boolean") return (!!value) as {};
    / Type '{}' is not assignable to type 'T'.
    //  '{}' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{}'.ts(2322)
}

EDIT: MAde a bunch of mistakes when copying this over

@MicahZoltu
Copy link
Contributor

@Gianthra Despite me being the one to refer you to this thread, I think I was mistaken originally and your problem is actually different. In your case, the problem lies in the fact that T could be a more constrained type than boolean. This will cause typeof default === 'boolean' to resolve to true, but T may be the type false.

@jcalz
Copy link
Contributor

jcalz commented Aug 27, 2019

Some of these issues seem related to #13995 #33014 / #33912 ... if an unresolved generic type parameter could be narrowed to something concrete inside a function implementation, the conditional type would also be resolved.

@iazel
Copy link

iazel commented Oct 23, 2019

I also stumbled upon another valid use case: dynamic restrictions based on some attribute.

In my particular case, I'm trying to build an UI library based on Command/Handler approach, where we can model actions in form of data that will be later interpreted and executed by a dynamic handler.

Example:

type Action<T extends symbol = any, R extends {} = any> = {
    type: T
    restriction: R
}
type TagR<T extends string> = {tag: T}

Action is the base on which all the others are built, while TagR is a restriction that will allow to use certain actions only on specific html elements. Such an action could be adding an onInput event handler, which only makes sense for input and textarea, (etc...) elements:

const OnInputType = Symbol()
type EventHandler = (ev: InputEvent) => void
type OnInputAction = {
    type: typeof OnInputType,
    handler: EventHandler,
    restriction: TagR<'input' | 'textarea'>
}
const onInput = (handler: EventHandler): OnInputAction => TODO

Please note that restriction field is set to TagR<'input'|'textarea'>, as we said before.

Next we need an action that models an element:

const ElementType = Symbol()
type ElementAction<T extends string, A extends Action> = {
    type: typeof ElementType,
    tag: T,
    actions: A[]
    restriction: ElemR<A>
}

type ElemR<A extends Action> = UtoI<
   RemoveRestr<TagR<any>, A['restriction']>
>
type RemoveRestr<R, AR> =
    Pick<AR, Exclude<keyof AR, keyof R>>

// From Union to Intersaction: UtoI<A | B> = A & B 
type UtoI<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never;

The most interesting part is RemoveRestr, which will allow restrictions to bubble up, so that some other action can take care of it, allowing an implementation similar to React Context but much more type-safe.

This however is only half of the picture, the last piece is in the action creator:

const h = <T extends string, A extends Action>(
    tag: T,
    actions: MatchRestr<TagR<T>, A>[]
): ElementAction<T, A, ElemR<A>> =>
    TODO()

type MatchRestr<R, A> =
    A extends Action<any, infer AR>
        ? R extends Pick<AR, Extract<keyof AR, keyof R>> ? A : never
        : never 

MatchRestr will ensure that only actions that either matches it or do not require this kind of restriction will be assignable.

So, given all this code we now have that:

h('input', [onInput(TODO)])

Works as expected given that restrictions matches

h('div', [
    h('input', [onInput(TODO)]),
    h('br', [])
])

Works too given that element do not require any constraint and rather remove the onInput one.

h('div', [onInput(TODO)])

This will raise a type error!

So far, so good. The problem arise as soon as we try to abstract some of it. Let's say that we want a wrapper element and it should only receives other children elements:

const wrapper = <E extends ElementAction<any, any>>(children: E[]) =>
    h('div', children)

This raises a type-error:

Argument of type 'E[]' is not assignable to parameter of type 'MatchRestr<TagR<"div">, E>[]'.
  Type 'E' is not assignable to type 'MatchRestr<TagR<"div">, E>'.
    Type 'ElementAction<any, any>' is not assignable to type 'MatchRestr<TagR<"div">, E>'.

^ this I initially didn't expect, but anyway tried to solve it like this:

<E extends ElementAction<any, any>>(children: MatchRestr<TagR<any>, E>[]) => ...

And it indeed works, however as soon as I try to add something different it will break again:

const wrapper = <E extends ElementAction<any, any>>(children: MatchRestr<TagR<any>, E>[]) =>
    h('input', [
        onInput(TODO),
        ...children
    ])
Type 'MatchRestr<TagR<any>, E>' is not assignable to type 'OnInputAction | MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
  Type 'Action<any, {}> & E' is not assignable to type 'OnInputAction | MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
    Type 'ElementAction<any, any>' is not assignable to type 'OnInputAction | MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
      Type 'ElementAction<any, any>' is not assignable to type 'MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
        Type 'Action<any, {}> & E' is not assignable to type 'OnInputAction'.
          Type 'ElementAction<any, any>' is not assignable to type 'OnInputAction | MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
            Type 'ElementAction<any, any>' is not assignable to type 'MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>'.
              Type 'MatchRestr<TagR<any>, E>' is not assignable to type 'OnInputAction'.
                Property 'handler' is missing in type 'Action<any, {}> & ElementAction<any, any>' but required in type 'OnInputAction'.
                  Property 'handler' is missing in type 'ElementAction<any, any>' but required in type 'OnInputAction'.

Not sure why it infers as Action<any, {}> & E when it starts as OnInputAction | MatchRestr<TagR<"input">, MatchRestr<TagR<any>, E>>.

Please note that explicitly setting the type variables will solve the problem:

const wrapper = <E extends ElementAction<any, any>>(children: MatchRestr<TagR<any>, E>[]) =>
    h<'input', OnInputAction|E>('input', [
        onInput(TODO),
        ...children
    ])

Here is it in the Playground

@husnain129
Copy link

husnain129 commented Jul 20, 2022

I am working on my API hook in nextjs project but facing this kind of issue.

interface User {
  name: string;
}
interface Admin {
  name: string;
  age: number;
}
interface Auth {
  user: User;
  admin: Admin;
}
const Auth = <Name extends keyof Auth>(name: Name, body: Auth[Name]) => {
  if (name == "admin") {
    // here typeof body must be Auth['admin'] but it not give any suggestion .. only gives body.name
  }
};

@RyanCavanaugh
Copy link
Member

@husnain129 not a correct assumption, Auth<"name" | "admin">("admin", "hello") is a legal call

@juona
Copy link

juona commented Aug 11, 2022

@husnain129 not a correct assumption, Auth<"name" | "admin">("admin", "hello") is a legal call

@RyanCavanaugh not sure if that's true... I have just tried what you wrote in the playground and it yields an error. Am I missing something?

Playground

@RyanCavanaugh
Copy link
Member

Typo, should be

Auth<"user" | "admin">("admin", { name: "" });

@theabdulmateen
Copy link

I am working on my API hook in nextjs project but facing this kind of issue.

interface User {
  name: string;
}
interface Admin {
  name: string;
  age: number;
}
interface Auth {
  user: User;
  admin: Admin;
}
const Auth = <Name extends keyof Auth>(name: Name, body: Auth[Name]) => {
  if (name == "admin") {
    // here typeof body must be Auth['admin'] but it not give any suggestion .. only gives body.name
  }
};

A 'workaround' would be to use the is keyword.

edited playground

@githorse
Copy link

githorse commented Sep 7, 2023

Another simple illustration at this StackOverflow question:

function isFoxy<T>(x: T, flag: T extends object ? string : number) {
    console.log(`hmm, not sure`, x, flag)
}

type Fox = { fox: string }

function foxify<F extends Fox>(fox: F) {
    isFoxy(fox, 'yes')  // ❌ type error here
}

This is a contrived example, obviously, but only slightly less complicated than the real-world one I'm facing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.