Skip to content

Incorrect variance inference when parameters of an interface with eventually different variances are bundled in an object type #56069

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

Closed
geoffreytools opened this issue Oct 11, 2023 · 8 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@geoffreytools
Copy link

🔎 Search Terms

"variance", "object type", "interface", "higher order type", "type constraint", "generics"

🕗 Version & Regression Information

This is the behavior in every version I tried, and I reviewed the FAQ for entries about variance

⏯ Playground Link

https://www.typescriptlang.org/play?ts=5.2.2#code/C4TwDgpgBAYg9nAPASQHYBooHkCuwB8UAvFAMIBOEAhsBPEgN5QCWqAXFGpnHh7sFAC++AFAjWtcgDMqAY2gVqteogAKUCAA9aqACYBnWAlVVyVALb7CDEVBbsoACllxUwMx1UBtAOSsfALoAlMSEAG5wzLq2UDzAHI4hRITePnGBIoJioJBGcCZmlsRQTKwcVKgg3LxQFSBCANzZ4NBousyUssAqACoa2hB6hir67qwA5pioOOYARhDk+IQkPU0iOa30xW0dEF0qI2Ook1AAjEsiAPSXdrd39w92AH4vr29vVzc9LVA+h+QTTDnHxQXRwCCGVBwAT6GjMfRSerAAAW0BcqFGZgkv3+gKg0zmC3wPgAdJ87N9cj4CfNyCD4fjobV9PpmONUFRZgAbaDAOBQDa-U4+MRXABUUCkzE0UDFl2auQAoppIF1ED1liUoF4ANL2KAAawgIDgUigPQCHB6uoCQgVrT0u32CBg0ogunV-R0BigytVwEQuOOUxmtKWmtW9s49CwepIO063Rdbo9QZO50I12wOrElwl6PccC5svl6x+ACFTCgMNg8JrFDQIJXyIhSg40EJMPxROI3AsZPIyJRG821F7Bj7mwULPou3WSjEyk4Cx4oKl-MFQlAIlEYnEEklCPxMlGp6YZ8U2+VKo0owm9sBR30tN7DKPMXiaUSI2tBchm9sjqJqO75HOmSxQFmsainmUCwuY0BhKYzAVIOcpRqQcAqFwtYEMUDa0JhKhXpwNb7rhQg9hI-ZyAow6EVhCBji+E7DMY56WNYi4OOuqAZHY5GpOkAQnmWuT3l0RFMc+AxDGQjFIB+wb4qG37FJGYmtFJ-LxkBD7aYgBk+FQPiYAATBBUE6kAA

💻 Code

type Foo<In, Out> = CreateFoo<{ in: In, out: Out }>

interface CreateFoo<P extends FooParams> {
  in: (contra: P['in']) => void
  out: () => P['out']
}

type FooParams = { in: any, out: any };

type IndirectFoo<T extends Foo<string, number>> = T;

type IFoo = IndirectFoo<Foo<string, 1>>
//                      ~~~~~~~~~~~~~~
// Type 'Foo<string, 1>' does not satisfy the constraint 'Foo<string, number>'.
//  Type 'number' is not assignable to type '1'
/* fix */

type Expect<T> = { [K in keyof T]: T[K] }

type IndirectFooFixed<T extends Expect<Foo<string, number>>> = T;

type IFooOK = IndirectFooFixed<Foo<string, 1>> // OK
/* control */

type Bar<In, Out> = CreateBar<{ in: In }, Out>

interface CreateBar<P extends BarParams, Out> {
  in: (contra: P['in']) => void
  out: () => Out
}

type BarParams = { in: any };

type IndirectBar<T extends Bar<string, number>> = T;

type IBar = IndirectBar<Bar<string, 1>> // OK
/* same variance */

type CoFoo<In, Out> = CreateCoFoo<{ in: In, out: Out }>

interface CreateCoFoo<P extends FooParams> {
  in: P['in']
  out: P['out']
}

type IndirectCoFoo<T extends CoFoo<string, number>> = T;

type ICoFoo = IndirectCoFoo<CoFoo<'a', 2>> // OK

🙁 Actual behavior

Foo has become invariant on In and Out (it would not accept Foo<unknown, number> either).

🙂 Expected behavior

I would expect wrapping parameters in an object to be transparent. Foo should remain contravariant on In and covariant on Out

Additional information about the issue

It looks like variance information is not propagated correctly specifically when

  • there is an interaction between an object type and an interface: when Expect is used to convert the interface into an object type, the variance check works properly;
  • the eventual variance of the object fields in the target interface is not the same: CoFoo is covariant on both In and Out as expected and a ContraFoo would behave similarly.
@RyanCavanaugh RyanCavanaugh added the Design Limitation Constraints of the existing architecture prevent this from being fixed label Oct 11, 2023
@RyanCavanaugh
Copy link
Member

I don't really see a good possible fix here. Variance is computed per type parameter, so CreateFoo is measured to be invariant on P, not a per-field-split variance.

The workarounds work because they either cause a structural type relation to occur instead of a variance-based one, or split out the type parameter so that each one has its own variance.

@geoffreytools
Copy link
Author

I don't know how it works internally, I imagine you compute variance during creation and one needs to consider interface merging, but if the variance check is followed by a structural check, then maybe considering P as bivariant and letting the other mechanism handle the rest would do the trick?

@RyanCavanaugh
Copy link
Member

Falling back to structural comparison when a variance comparison is available is somewhere between a 40% and (never terminates) hit to performance depending on the codebase

@geoffreytools
Copy link
Author

geoffreytools commented Oct 11, 2023

Correct me if I am wrong, but if someone needs to have an interface with arbitrary/dynamic arity that supports variance (I used distinct fields in CreateFoo but it doesn't have to be this way), there is no other way than relying on a structural check. If P was { in: (contra: In) => void; out: () => Out }, then CreateFoo would have to be checked structurally anyway, wouldn't it?

At current no one uses this pattern because it does not work, so I imagine the only additional overhead for 100% of code bases would be limited to the additional logic required to single out this case.

I understand that, if it is a feature request, it is not appealing. The use case that really benefits from it is niche and there is a workaround.

@RyanCavanaugh
Copy link
Member

I think the targeted form of that request would "just" be to add a variance annotation that more or less disables variance-based relation (a thing that can happen organically today anyway):

interface Foo<unreliable T> { } // bikeshed name

I'm not sure I'd feel comfortable handing such a dangerous weapon to users, though.

FWIW we've tried to fix this once already and had to revert it, see #54754 / #54781 and the links from there

@geoffreytools
Copy link
Author

Yes I was hesitant to suggest that. An annotation which sole purpose is to disable an optimisation is too coupled with the internals of the compiler to be added to the language, especially if you are actively trying to solve this problem by other means. We could be stuck with a useless annotation.

@geoffreytools
Copy link
Author

geoffreytools commented Oct 12, 2023

I realise one can do abusive stuff with this

type Foo<P extends unknown[]> = CreateFoo<[
    // we set arbitrary variances
    P[0],
    (a: P[1]) => void,
    (a: P[2]) => P[2]
]>

interface CreateFoo<P extends any[]> {
    // we turn everything back to covariant
    covariant: [
        P[0],
        Parameters<P[1]>[0],
        ReturnType<P[2]>
    ]
    // some other field
    overrideMe: any
}

type Direct = CovariantCheck<Foo<[number, string, boolean]>, [2, 'a', true]> // OK

type CovariantCheck<T extends { covariant: any }, U extends T['covariant']> = never

type Indirect<T extends Foo<[number, string, boolean]>> =
    CovariantCheck<T, [2, 'a', true]>; // Generic OK

type OriginalVarianceCheck = Indirect<Foo<[2, unknown, boolean]>> // OK

If it didn't rely on unintended behaviour this would actually be pretty need for implementing HKT because, like functions, you don't check variance the same way when you apply it with arguments and when you pass it to a higher order function.

Now this state is actually pretty fragile because overriding any field falls back to the structural check

interface Bar extends Foo<[2, unknown, boolean]> {
    overrideMe: any
}

type IBar = Indirect<Bar>
//                   ~~~
// Type 'unknown' is not assignable to type 'string'

playground

@rotu
Copy link

rotu commented Jan 2, 2024

I think the original issue is in error.

Object properties are unsuitable for contravariant type parameters. TypeScript has no writeonly properties and even rejects an explicit contravariance declaration on an object type alias:

type FooParams<in X, out Y> = { x: X, y: Y };
// Type 'FooParams<super-X, Y>' is not assignable to type 'FooParams<sub-X, Y>' as implied by variance annotation.
//   Types of property 'x' are incompatible.
//     Type 'super-X' is not assignable to type 'sub-X'.

A good alternative, if you want to keep FooParams as-is, is to destructure FooParams into individual type variables.

interface CreateFoo<
  P extends FooParams, // type is *independent* of P
  PIn = P['in'], // inferred as contravariant
  POut = P['out'] // inferred as covariant
> {
    in: (contra: PIn) => void
    out: () => POut
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

3 participants