TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Comparing constrained generic types/substitution types to conditional types

Open mhegazy opened this issue 6 years ago • 17 comments

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);
    }
}

mhegazy avatar Apr 04 '18 00:04 mhegazy

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

jack-williams avatar Apr 04 '18 18:04 jack-williams

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

mhegazy avatar Apr 04 '18 20:04 mhegazy

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.

jack-williams avatar Apr 04 '18 20:04 jack-williams

Looking for concrete examples of where this comes up more legitimately

RyanCavanaugh avatar Apr 17 '18 18:04 RyanCavanaugh

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

kevinbeal avatar May 01 '18 16:05 kevinbeal

Two semi-legitimate examples from #25883:

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

mattmccutchen avatar Aug 03 '18 02:08 mattmccutchen

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

Andarist avatar Mar 02 '19 10:03 Andarist

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 avatar Jul 30 '19 06:07 MicahZoltu

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)".

MicahZoltu avatar Aug 06 '19 11:08 MicahZoltu

@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

michaeljota avatar Aug 07 '19 20:08 michaeljota

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

Gianthra avatar Aug 19 '19 12:08 Gianthra

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

MicahZoltu avatar Aug 19 '19 13:08 MicahZoltu

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.

jcalz avatar Aug 27 '19 15:08 jcalz

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

iazel avatar Oct 23 '19 21:10 iazel

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

husnain129 avatar Jul 20 '22 16:07 husnain129

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

RyanCavanaugh avatar Jul 20 '22 17:07 RyanCavanaugh

@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

juona avatar Aug 11 '22 07:08 juona

Typo, should be

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

RyanCavanaugh avatar Aug 16 '22 18:08 RyanCavanaugh

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

theabdulmateen avatar Nov 29 '22 19:11 theabdulmateen

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.

githorse avatar Sep 07 '23 18:09 githorse