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

Generic Extended Types not inferring Correctly #48741

Closed
jaredjbarnes opened this issue Apr 17, 2022 · 21 comments
Closed

Generic Extended Types not inferring Correctly #48741

jaredjbarnes opened this issue Apr 17, 2022 · 21 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@jaredjbarnes
Copy link

Bug Report

🔎 Search Terms

  • Generic Extended Types not inferring Correctly
  • Inferred generics

🕗 Version & Regression Information

All versions have this problem.

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about "Inferred types & Generics"

⏯ Playground Link

Playground link with relevant code

💻 Code

type UnWrapValue<T> = T extends Array<infer TValue> ? TValue | null : never;

type UnWrapObject<T> = {
  [P in keyof T]: UnWrapValue<T[P]>;
};

interface DefaultProps {
  prop1: number[];
  prop2: string[];
}

interface ExtendedProps extends DefaultProps {
  prop3: boolean[];
}

class SuperKlass<T> {
  unwrapped!: UnWrapObject<T>;
  original!: T;
}

// It appears to me that the default generic type isn't being accounted for here.
// I would expect T to be at least DefaultProps and and be able to get that typing, however, it 
// is unable to infer the typing.
class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    // Why does the typing NOT work here?
    this.unwrapped.prop1 = 1;
  }
}

class OneDeeper extends SubKlass<ExtendedProps> {
  method2(){
    // Why does the typing work here?
    this.unwrapped.prop1 = 1;
    this.unwrapped.prop3 = false;
  }
}

🙁 Actual behavior

Unwrapping a constrained generic type with "infer" fails to do so properly.

🙂 Expected behavior

"this.unwrapped.prop1" should be type "string" within "SubKlass".

@MartinJohns
Copy link
Contributor

// I would expect T to be at least DefaultProps and and be able to get that typing

This is a very common misconception. Your generic type is extending DefaultProps, but it is not a DefaultProps. The generic type can be more specific.

For example:

interface MyType {
  prop1: (5 | 6)[]
  prop2: ("a" | "b")[]
}

new SubKlass<MyType>()

Assigning 1 to prop1 would be invalid, as the type only accepts 5 or 6.

@jaredjbarnes
Copy link
Author

jaredjbarnes commented Apr 17, 2022

I'm not sure that that answers the question. If you look at the example in the playground and inside the SubKlass class you write

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    // Why does the typing not work here?
    this.unwrapped.prop1 = 1;
    this.original.prop1; // This line correctly says that "prop1" is a "string[]"
  }
}

So the question is why doesn't the unwrapping work, and correctly give me the inferred type of that array.

I'm probably missing something, or I'm not explaining myself well.... probably both.

@fatcerberus
Copy link

fatcerberus commented Apr 18, 2022

This is why:

interface ExtendedProps2 extends DefaultProps {
  prop1: (2 | 3)[];
}

class OneDeeper2 extends SubKlass<ExtendedProps2> {
  method2(){
    this.unwrapped.prop1 = 1; // correct error
  }
}

Since SubKlass<ExtendedProps2> is a valid instantiation of SubKlass, the assignment inside SubKlass#method1 is not sound and the compiler correctly flags it as an error. There's no error inside OneDeeper because, in that case, SubKlass has already been instantiated as a concrete type and prop1 is known be number | null.

In short: The compiler is enforcing that SubKlass's implementation works correctly for any type of T.

@jaredjbarnes
Copy link
Author

@fatcerberus That part totally makes sense to me. The part that is broken isn't within the "OneDeeper" class, its within the "SubKlass" class.

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    this.unwrapped.prop1 = 1; // This does NOT work as expected. This should have been type number, but it doesn't infer that.
    this.original.prop1.push(1); // This correctly infers from T that is has to be at least an array of numbers.
  }
}
``

@fatcerberus
Copy link

fatcerberus commented Apr 18, 2022

And that's what I'm trying to tell you - the error in SubKlass is expected:

interface ExtendedProps2 extends DefaultProps {
  prop1: (2 | 3)[];
}

const foo = new SubKlass<ExtendedProps2>();  // legal instantiation
foo.method1();  // oops! foo.unwrapped.prop1 is now 1 but this is not legal for a SubKlass<ExtendedProps2>

The error prevents you from making this mistake.

@jaredjbarnes
Copy link
Author

jaredjbarnes commented Apr 18, 2022

@fatcerberus
But the error doesn't prevent this on the "original" property of the "SubKlass". Let me explain.

What do you think should happen if I call method1 in SubKlass that looks like this.

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
   // Should this allow me to push into the array of numbers with any number?
    this.original.prop1.push(1);
  }
}

In this case it works as I would expect. Because T extends DefaultProps, it knows that type "T" MUST have a property of "prop1" of type "number[]" based on the constraint provided to the generic.

Now lets modify the method1 to look like this.

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    // I expected this to be type "number" within SubKlass. Why isn't it? It had the same constraints on T. 
   // Why does T not send its constraints to UnWrapObject<T>.
    this.unwrapped.prop1 = 1;
  }
}

I'm really trying to understand, It doesn't make sense to me that inside of the "SubKlass" class the "original" property knows that T has to be at least a "number[]" because it extends "DefaultProps", but when I send T to be unwrapped through UnWrapObject it forgets that T extends "DefaultProps", and therefore has two properties with specific types.

Don't give up on explaining. I want to understand why T at times acts as expected within "SubKlass", but then, when I infer what the array types are it seems to forget that T extends DefaultProps.

@fatcerberus
Copy link

fatcerberus commented Apr 18, 2022

In this case it works as I would expect. Because T extends DefaultProps, it knows that type "T" MUST have a property of "prop1" of type "number[]" based on the constraint provided to the generic.

This is not strictly true, T may have a property prop1 of type (e.g.) (2 | 3)[] as I illustrated above. The push call should be an error for the same reason; I'm not sure why it isn't.

@whzx5byb
Copy link

@jaredjbarnes

method1(){
   // Should this allow me to push into the array of numbers with any number?
    this.original.prop1.push(1);
}

This is a common unsoundness caused by mutability. To not break type soundness, mutable properties, and mutable arrays, must be invariant. However in ts they are covariant, that's why this.original.prop1.push(1) is allowed.

See a more detailed answer in SO

@jaredjbarnes
Copy link
Author

@whzx5byb
So with "this.original.prop1.push(1)" being covariant, wouldn't that be the same for "this.unwrapped.prop1". I suppose this is what seems wrong to me. If "this.original.prop1" is covariant then shouldn't "this.unwrapped.prop1" be too.

If you look at the code in the playground you will see the error, that to me, seems broken. It reads,
Type '1' is not assignable to type 'UnWrapValue<T["prop1"]>'.(2322) It's acting like it has no clue that type "T" extends "DefaultProps".

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    this.original.prop1.push(1);
    this.unwrapped.prop1 = 1; // <-- This is where the bug is to me. 
  }
}

For me it doesn't make sense to have "T extends DefaultProps" be anything but covariant. I don't see how contravariant would be helpful here, nor invariant. The keyword extends implies covariant, but I could be missing something.

@jaredjbarnes
Copy link
Author

Here is a more concise example of what I believe to be a bug.

Playground example

type UnWrapValue<T> = T extends Array<infer TValue> ? TValue | null : never;

type UnWrapObject<T> = {
  [P in keyof T]: UnWrapValue<T[P]>;
};

interface DefaultProps {
  prop1: number[];
  prop2: string[];
}


interface P extends DefaultProps  {
  prop3: boolean[];
}

// Unwrapping P here works as expected 
const a: UnWrapObject<P> = {
  prop1: 1,
  prop2: "",
  prop3: false
};

class Car<P extends DefaultProps> {
  unwrapped!: UnWrapObject<P> // Unwrapping here is broken.

  method(){
    this.unwrapped.prop1 = 1; // <--- This is the bug and should be type number.
    this.unwrapped.prop2 = ""; // <--- This is the bug and should be type string.
  }
}

@fatcerberus
Copy link

fatcerberus commented Apr 18, 2022

Variance kind of clouds the issue here. T extends DefaultProps means that T is a subtype of DefaultProps. So yes, that part is covariant. However, for writes to a generic type to be sound, they must be contravariant. This severely limits what you can safely hard-code into a generic function or class.

The error you're seeing is not a bug, unless you believe this behavior should be allowed:

interface Fooey extends DefaultProps { prop1: (40 | 41 | 42)[] }
const foo = new SubKlass<Fooey>();
foo.method1();
console.log(foo.unwrapped.prop1);  // prints 1, even though this should be impossible given the types involved

I don't know how much clearer I can make this. It's true that this is allowed by the compiler:

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    this.original.prop1 = [ 1, 2, 3 ];
  }
}

But that's not safe for the exact same reason and should be an error. However, perfect soundness is not a goal in the design of the type system, so this case probably just got overlooked.

@jaredjbarnes
Copy link
Author

@fatcerberus
Well said. :)

@jaredjbarnes
Copy link
Author

I assumed a narrowing of "T" as developers inherited "SubKlass", but this doesn't HAVE to happen as @fatcerberus explained so well above. Developers could just narrow "T" and break the typing within the "SubKlass". This is allowed, probably, to be practical.

Here is what I assumed in psuedo code.

interface Props {
   prop1: string[];
   prop2: number[];
};

interface NarrowedProps {
   prop1: ["Foo", "Bar"],
   prop2: [1, 2]
}

class Klass <T extends Props> {
    props!: T;
    method(){
        this.props.prop2.push(3);
    }
}

class SubKlass extends Klass<NarrowedProps> {
    override method(){
        this.props.prop2[0] = 1
    }
}

// To this point, this all works well, however, the type system would also allow this.
// And in this case the type system doesn't protect you from runtime errors. 
const c = new Klass<NarrowedProps>();

This all makes sense that the type system has to be practical. What I would like to see, is that the unwrapping of "T" when extending Props would infer the correct types. This is where I believe the inconsistency is difficult to deal with. I gave an example above that showed unwrapping being inconsistent when dealing with interface vs classes. That is my true concern.

@fatcerberus
Copy link

fatcerberus commented Apr 18, 2022

What is UnWrapObject<T>? It could be { prop1: number }, or it could be { prop1: 1 | 2 | 3 }. We don't know for sure until we also know what T is, since as already established, existing properties can be narrowed by subtypes. I think that's contributing to the confusion here. In the case of original, TS is just (unsoundly) using the constraint of T (for convenience, most likely), but it won't do that when feeding T through another type alias.

@jaredjbarnes
Copy link
Author

@fatcerberus I see, well said again. So it was a compromise they made with "T" within "SubKlass", but did not make the same compromise when using "infer" within the UnWrapValue. I think I understand now. How do we find out if this was intentional or not?

@RyanCavanaugh
Copy link
Member

Generally if something has been the behavior for multiple years, it's intentional. Finding years-old bugs in plain view in widely-used software is quite rare.

This thread is pretty long, if you want to construct a short example that demonstrates the question I'd be happy to speak to it.

@jaredjbarnes
Copy link
Author

@RyanCavanaugh This best explains what I see as a bug. With a playground.

Best Example

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Apr 18, 2022
@RyanCavanaugh
Copy link
Member

Gotcha. I don't have anything to add over @fatcerberus 's answer re: that one; it's the correct behavior.

@jaredjbarnes
Copy link
Author

@RyanCavanaugh Thanks guys

@whzx5byb
Copy link

whzx5byb commented Apr 18, 2022

@jaredjbarnes

I believe the essential problem is some mechanisms called 'Assignability to Conditional Types'. This logic is described in #46429, and #30639 (an earlier implementation before v4.5).

Let's begin with a simple code:

type Foo<T> = T extends infer U ? U : never;

function fn<T extends number>(x: Foo<T>, s: number) {
    var v1: Foo<number> = 0 as any;
    //   ^? v1: number
    //   The result of Foo<number> is evaluated as number.
    var v2: Foo<T> = 0 as any;
    //   ^? v2: Foo<T>
    //   The result of Foo<T> is lazy-evaluating.

    v1.toExponential(); 
    // Compiles. v1 is type number.
    v2.toExponential();
    // Compiles. v2 is treated as type number.

    v1 = 1;
    // Compiles. 
    v2 = 1;
    // Error. Type 'number' is not assignable to type 'Foo<T>'.
}

As #46429 says, Type 'number' is not assignable to type 'Foo<T>' because:

  • The conditional type is distributive, and A (T) is referenced in C (T).
  • B (infer U) has infer positions.
  • S (number) is not assignable to C (T).
  • S (number) is not assignable to D (never).

I think this could answer the question you raised in origin post.

class SubKlass<T extends DefaultProps = DefaultProps> extends SuperKlass<T> {
  method1(){
    this.unwrapped.prop1 = 1;
  }
}

Why does the typing NOT work here?

Because T is not evaluated, so this.unwrapped.prop1 is a lazy-evaluating conditional type UnWrapValue<T>. You can't assign number to it, as I explained above.

class OneDeeper extends SubKlass<ExtendedProps> {
  method2(){
    this.unwrapped.prop1 = 1;
    this.unwrapped.prop3 = false;
  }
}

Why does the typing work here?

Because T is evaluated as ExtendedProps, and this.unwrapped.prop1 is no longer an unresolved conditional type. Its value is evaluated as number | null. That's why you can assign number to it.

@jaredjbarnes
Copy link
Author

@whzx5byb What a beautiful reply. That was really helpful, thank you :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants