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

Referencing ReturnType<FooT> in generic param, and passing a function that has one param causes incorrect type inference... #29133

Closed
AnyhowStep opened this issue Dec 23, 2018 · 11 comments
Labels
Unactionable There isn't something we can do with this issue

Comments

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Dec 23, 2018

TypeScript Version: 3.3.0-dev.20181129

Search Terms: ReturnType, Generic Param, Generic Function Argument

Code

//Innocent enough.
//Take a T, return a T
type Foo<T extends number> = (t: T) => T;

//=== NOT using ReturnType<FooT> in Generic Parameters ==
declare function bar<
    T extends number,
    FooT extends Foo<T>
>(t: T, foo: FooT): ReturnType<FooT>
//OK! `barResult` is of type `3`
const barResult = bar(
    3 as (3|5),
    () => 3
);
//OK! `barResult2` is of type `3`
const barResult2 = bar(
    3 as (3|5),
    //`t` is of type `3|5`
    //Return type is 3 
    t => 3
);

//=== Using ReturnType<FooT> in Generic Parameters ==
declare function baz<
    T extends number,
    FooT extends Foo<T>,
    //The same as bar<> but we reference ReturnType<FooT> here
    Ret extends ReturnType<FooT>
>(t: T, foo: FooT, ret: Ret): ReturnType<FooT>
//OK! `bazResult` is of type `3`
const bazResult = baz(
    3 as (3|5),
    () => 3,
    3
);
//NOT OK! `bazResult2` is of type `3|5`!
const bazResult2 = baz(
    3 as (3|5),
    //`t` is of type `3|5`
    //Return type is `3`
    t => 3,
    3
);

//NOT OK! `bazResult3` is of type `3|5`!
const bazResult3 = baz(
    3 as (3|5),
    //`t` is of type `3|5`
    //Return type is `3`
    //We *force* the return type to be 3
    (t) : 3 => (3 as 3),
    3
);

Expected behavior:

bazResult2 and bazResult3 should be of type 3, the same as bazResult

Actual behavior:

bazResult2 and bazResult3 are of type 3|5, different from bazResult (which is of type 3)

The difference between bazResult and bazResult2 is literally 3 characters but it causes the inferred types to be very different.

Playground Link: Here


I don't even know how to describe this...

My real use case is more complicated than the above example but I need the return type of a generic function to be inferred correctly to perform stronger compile-time type checks.

The strange thing is that () => 3 will give me the correct return type, but t => 3 will not.
In my own code, using the argument t is absolutely necessary.

As far as I've seen, this problem only occurs both the following are true,

  1. ReturnType<FooT> is used in a generic param constraint
  2. The concrete type of FooT has parameters (Like t => 3 has parameters, () => 3 does not)

If either of the above are false, this problem does not occur

@AnyhowStep AnyhowStep changed the title Referencing ReturnType<Foo> in generic param causes incorrect type inference... Referencing ReturnType<FooT> in generic param causes incorrect type inference... Dec 23, 2018
@AnyhowStep AnyhowStep changed the title Referencing ReturnType<FooT> in generic param causes incorrect type inference... Referencing ReturnType<FooT> in generic param, and passing a function that has one param causes incorrect type inference... Dec 23, 2018
@AnyhowStep
Copy link
Contributor Author

Shorter repro,

//Innocent enough.
//Returns a T
type Foo<T> = (...args : any[]) => T;

//=== NOT using ReturnType<FooT> in Generic Parameters ==
declare function bar<
    FooT extends Foo<3|5>
>(foo: FooT): ReturnType<FooT>
//OK! `barResult` is of type `3`
const barResult = bar(
    () => 3
);
//OK! `barResult2` is of type `3`
const barResult2 = bar(
    //`t` is of type `3|5`
    //Return type is 3 
    t => 3
);

//=== Using ReturnType<FooT> in Generic Parameters ==
declare function baz<
    FooT extends Foo<3|5>,
    //The same as bar<> but we reference ReturnType<FooT> here
    Ret extends ReturnType<FooT>
>(foo: FooT, ret: Ret): ReturnType<FooT>
//OK! `bazResult` is of type `3`
const bazResult = baz(
    () => 3,
    3
);
//NOT OK! `bazResult2` is of type `3|5`!
const bazResult2 = baz(
    //`t` is of type `3|5`
    //Return type is `3`
    t => 3,
    3
);

//NOT OK! `bazResult3` is of type `3|5`!
const bazResult3 = baz(
    //`t` is of type `3|5`
    //Return type is `3`
    //We *force* the return type to be 3
    (t) : 3 => (3 as 3),
    3 as 3
);

Playground

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Dec 23, 2018

This is a different repro,

It doesn't use ReturnType<FooT> in the generic params. It uses it in the function param.

//Innocent enough.
//Returns a T
type Foo<T> = (...args : any[]) => T;

//=== NOT using ReturnType<FooT> in Generic Parameters ==
declare function bar<
    FooT extends Foo<3|5>
>(foo: FooT): ReturnType<FooT>
//OK! `barResult` is of type `3`
const barResult = bar(
    () => 3
);
//OK! `barResult2` is of type `3`
const barResult2 = bar(
    //`t` is of type `3|5`
    //Return type is 3 
    t => 3
);

//=== Using ReturnType<FooT> in Generic Parameters ==
declare function baz<
    FooT extends Foo<3|5>,
>(
    foo: FooT & (
        ReturnType<FooT> extends 3 ?
        unknown :
        [
            "Only 3 allowed, received",
            ReturnType<FooT>
        ]
    )
): ReturnType<FooT>
//OK! `bazResult` is of type `3`
const bazResult = baz(
    () => 3
);
//NOT OK! `bazResult2` is of type `3|5`!
const bazResult2 = baz(
    //`t` is of type `3|5`
    //Return type is `3`
    //Error, ["Only 3 allowed, received", 3 | 5]
    t => 3
);

//NOT OK! `bazResult3` is of type `3|5`!
const bazResult3 = baz(
    //`t` is of type `3|5`
    //Return type is `3`
    //We *force* the return type to be 3
    //Error, ["Only 3 allowed, received", 3 | 5]
    (t) : 3 => (3 as 3)
);

Playground

Of note is bazResult3 where I went all-out and explicitly said that it returns 3 but the type checker still thinks it's 3|5


In this repro, the intention is compile-time error messages at the parameter that does not satisfy some potentially complicated constraint.

It could be re-written to have the checks in the return type and then returning never if the constraint fails but it loses the kind of safety I'm looking for.

For one, calling the function would still compile if I choose to return never.
What I want is for the function call itself to not compile if the constraint fails.

These kind of checks on parameters works for every other type I've tried. It's only functions that take arguments that fail. Functions that don't take arguments work fine.

Also, returning never when the constraint fails means no helpful error messages.

@weswigham weswigham added the Needs Investigation This issue needs a team member to investigate its status. label Dec 25, 2018
@AnyhowStep
Copy link
Contributor Author

Poked around a bit more and it looks like if I explicitly say what type the parameters are, it infers the return type just fine.

//OK! `bazResult2` is of type `3`
const bazResult2 = baz(
    (t : number) => 3
);

//OK! `bazResult3` is of type `3`
const bazResult3 = baz(
    (t : number) : 3 => (3 as 3)
);

But in my actual use-case, the parameter type is pretty complicated because it depends on chaining many generic types, and it uses a builder pattern.

@AnyhowStep
Copy link
Contributor Author

I've gotten TS to infer the return type of a generic function correctly, only in one part of my code.
I've been trying to dissect it and figure out what makes it work, when so many others fail.

Haven't made any headway, though. It's the most complicated of my types but it works for some reason ._.

@AnyhowStep
Copy link
Contributor Author

After poking around a bit more, it seems to work fine with classes.
As in, the return type is inferred correctly.

class C<T> {
    constructor(readonly t : T) {
        
    }
    baz<FooT extends ((t : T) => (T | undefined))>(
        foo: FooT & (
            ReturnType<FooT> extends 1|2?
            ["1|2 is not allowed, received", ReturnType<FooT>] :
            unknown
        )
    ) : C<ReturnType<FooT>> {
        return new C(foo(this.t) as ReturnType<FooT>);
    }
}
const c = new C(1 as 1|2|3);
const c2 = c.baz(() => undefined);
const c3 = c.baz(() => 3);
//'() => 2' is missing the following properties from type '["1|2 is not allowed, received", 2]'
const c4 = c.baz(() => 2);
//'(1|2|3) => 2' is missing the following properties from type '["1|2 is not allowed, received", 2]'
const c5 = c.baz(t => 2);
//'() => 1' is missing the following properties from type '["1|2 is not allowed, received", 1]'
const c6 = c.baz(() => 1);
//'(1|2|3) => 1' is missing the following properties from type '["1|2 is not allowed, received", 1]'
const c7 = c.baz(t => 1);

@AnyhowStep
Copy link
Contributor Author

Seems like classes really do run on different rules.

class C<T> {
    constructor(readonly t : T) {
        
    }
}

function baz<
    CType extends C<any>,
    FooT extends ((t: CType["t"]) => (CType["t"] | undefined)
)>(
    c : CType,
    foo: FooT & (
        ReturnType<FooT> extends 1|2?
        ["1|2 is not allowed, received", ReturnType<FooT>] :
        unknown
    )
) : C<ReturnType<FooT>> {
    return new C(foo(c.t) as ReturnType<FooT>);
}
const c = new C(1 as 1|2|3);
const c2 = baz(c, () => undefined);
const c2b = baz(c, t => undefined);
const c3 = baz(c, () => 3 as 3);
const c3b = baz(c, t => 3 as 3);
//Need to use `as 2` here but the error message is as expected
//'() => 2' is missing the following properties from type '["1|2 is not allowed, received", 2]' 
const c4 = baz(c, () => 2 as 2);
//Need to use `as 2` here but the error message is as expected
//'(1|2|3) => 2' is missing the following properties from type '["1|2 is not allowed, received", 2]'
const c5 = baz(c, t => 2 as 2);
//Need to use `as 1` here but the error message is as expected
//'() => 1' is missing the following properties from type '["1|2 is not allowed, received", 1]'
const c6 = baz(c, () => 1 as 1);
//Need to use `as 1` here but the error message is as expected
//'(1|2|3) => 1' is missing the following properties from type '["1|2 is not allowed, received", 1]'
const c7 = baz(c, t => 1 as 1);

@AnyhowStep
Copy link
Contributor Author

Okay. So, not all classes/interfaces are different. A bunch of my own code uses classes/interfaces and functions and ReturnType<FooT> but it just doesn't work =/

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented May 24, 2019

So, I decided to play with 3.5.0-dev.20190523,

I ran the above code samples, and only this one still fails,

//Innocent enough.
//Returns a T
type Foo<T> = (...args : any[]) => T;

//=== NOT using ReturnType<FooT> in Generic Parameters ==
declare function bar<
    FooT extends Foo<3|5>
>(foo: FooT): ReturnType<FooT>
//OK! `barResult` is of type `3`
const barResult = bar(
    () => 3
);
//OK! `barResult2` is of type `3`
const barResult2 = bar(
    //`t` is of type `3|5`
    //Return type is 3 
    t => 3
);

//=== Using ReturnType<FooT> in Generic Parameters ==
declare function baz<
    FooT extends Foo<3|5>,
>(
    foo: FooT & (
        ReturnType<FooT> extends 3 ?
        unknown :
        [
            "Only 3 allowed, received",
            ReturnType<FooT>
        ]
    )
): ReturnType<FooT>
//OK! `bazResult` is of type `3`
const bazResult = baz(
    () => 3
);
//NOT OK! `bazResult2` is of type `3|5`!
const bazResult2 = baz(
    //Return type is `3`
    //Error, ["Only 3 allowed, received", 3 | 5]
    t => 3
);

//NOT OK! `bazResult3` is of type `3|5`!
const bazResult3 = baz(
    //Return type is `3`
    //We *force* the return type to be 3
    //Error, ["Only 3 allowed, received", 3 | 5]
    (t) : 3 => (3 as 3)
);

Playground

I believe #30215 is what fixes most of whatever was error'ing before.

However, it would be nice if the above sample was fixed, too. I love putting error messages in parameters because it pinpoints the exact location of the problem.

TypeScript thinks the return type is 3|5, even when I explicitly say it is 3, this should never happen.
Even if I leave it implicit, it would be nice if it realized that the return type of t => 3 extends 3.

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented May 25, 2019

I've been playing with this a bit more and a cumbersome workaround is,

//Innocent enough.
//Returns a T
type Foo<T> = (...args : any[]) => T;

//=== Using ReturnType<FooT> in Generic Parameters ==
declare function baz<
    FooT extends Foo<3|5>,
>(
    foo: FooT
): (
    (this: (
        ReturnType<FooT> extends 3 ?
        unknown :
        [
            "Only 3 allowed, received",
            ReturnType<FooT>
        ]
    )) => ReturnType<FooT>
)
//OK! `bazResult` is of type `3`
const bazResult = baz(
    () => 3
);

//OK! `bazResult2` is of type `3`
const bazResult2 = baz(
    t => 3
)();

//OK! `bazResult3` is of type `3`
const bazResult3 = baz(
    (t) : 3 => (3 as 3)
)();

/*
    OK! `bazResult4` is of type `5`
    
    Error as expected,
    
    The 'this' context of type 'void' is not assignable to
    method's 'this' of type '["Only 3 allowed, received", 5]'.
*/
const bazResult4 = baz(
    t => 5
)();

Playground

Since ReturnType<FooT> does not work when using it in the function parameter list, we move it to the return type annotation.

We make the return type a function that uses ReturnType<FooT> in its this parameter, that becomes an impossible to fulfill type when some condition is fulfilled.

It's cumbersome because we now have to perform an extra function call,

baz(t => result)() vs baz(t => result).

Also, the error message is now on the function call and not the parameter itself.

But the extra function call has the benefit of making the error message much shorter and easier to read.

The 'this' context of type 'void' is not assignable to
    method's 'this' of type *Error Type*

vs, potentially,

image

Notice the length of the scroll bar on that tooltip... It's all just saying that one type is not assignable to another type.


But, of course, if we had some invalid type or type-level throw, as suggested in #23689 , then I may not need to go through all this

@AnyhowStep
Copy link
Contributor Author

@jack-williams
What do you think of my last two comments?

@RyanCavanaugh
Copy link
Member

@AnyhowStep please log a concise issue if needed. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Unactionable There isn't something we can do with this issue
Projects
None yet
Development

No branches or pull requests

3 participants