TypeScript: Variance Annotations do not impact "infer" correctly

🔎 Search Terms

Variance Annotations, infer, extends, type

🕗 Version & Regression Information

  • This is the behavior in every version I tried (4.7 +), and I reviewed the FAQ for entries about “bugs-that-arent-bugs”

⏯ Playground Link

https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgEJTiAJgHlMgewFcxkAVAPmQG8BfAKDAE8AHFAJQgGciAbUgLzIAFNSIgoEXnEhYAYsClYAXMhBEAtgCNotZADI0GbDi5gooAOYUAlMggAPSNi5HMuUDGjkqAfnLIqiAQAG7QANxAA

đŸ’» Code

interface Brand<in out T> {}
type Result = ({unrelatedField: number} & Brand<string>) extends Brand<infer T> ? T : never;

🙁 Actual behavior

Result is unknown

This is incorrect since the inferred type parameter is “string” not “unknown”. Additionally, “unknown” is not referred to anywhere in this code, so it’s very strange for it to get inferred.

🙂 Expected behavior

Result is string since the type extends Brand<string>.

This is how the example behaves if the “unrelatedField” is removed. Since that field is unrelated to the extends clause, it should have no impact on the inferred type. This is further evidence that “string” is the right result, but this case is messing that up somehow.

Additional information about the issue

Another workaround for this is to explicitly reference the type parameter in dummy fields. This is the pattern taken in my TypeCheck library to enable it to control variance before TypeScript 4.7. I found this issue when updating usages of that library to use the built-in variance annotation feature, and it broke some usages of infer.

About this issue

  • Original URL
  • State: closed
  • Created 9 months ago
  • Comments: 24 (9 by maintainers)

Most upvoted comments

This has less to do with variance annotations than it has to do with our approximations in inference, and so I feel the best label is “not a defect”.

See this example:

interface X<T> {}

declare function f<T>(x: X<T>): T;

let x = f({} as X<number>);
//  ^?
let y = f({} as X<number> & {});
//  ^?
let z = f({} as X<number> & { yadda: string });
//  ^?

When you can infer between two identical type references or aliases, our inference algorithm will avoid any sort of structural checks because it is unnecessary; however, introducing an object type with any contents means that inference must consider the holistic structure.

@MartinJohns and @fatcerberus have given the context around there being nothing to infer from in the underlying structure

Draft

Variance Annotations

Variance annotations are not common, and you should feel free to skip this section

TypeScript automatically infers the variance of generic types using its normal type comparison algorithm. In extremely rare cases, this inference process can produce results that are incorrect, or might take longer to measure than is ideal. If that happens, you can use a variance annotation to either correct or speed up the process.

You can optionally annotate type parameters of some types with the variance annotations in (contravariant), out (covariant), or in out (invariant):

// Correct covariant annotation
interface Producer<out T> {
  create(): T;
}

// Incorrect covariant annotation
interface ProducerWrongAnnotated<in T> {
  create(): T;
}

If you don’t know what those terms means, it’s best to simply avoid this feature, as it’s almost never strictly needed.

Variance annotations are an advanced feature designed to specifically address some very rare situations, and have some important limitations to keep in mind.

Because the purpose of variance annotations is to correct situations where TypeScript produced the wrong result, they aren’t strictly validated, so it’s critical to only write a variance annotation if you’re confident that you’re matching the correct structural variance. You should always write the same variance that would be structurally measured by TypeScript (modulo limitations). If you’re not sure what the correct variance of a type is, then it’s not a good situation to write a variance annotation.

Do not suppress errors about incorrect variance errors with ts-ignore or similar constructs. This will lead to surprising or incorrect results elsewhere. Trying to change the variance of a type parameter using a variance annotation will not succeed, because this is not what they are designed for.

Note that variance annotations do not change the actual variance of a type parameter as it occurs in an instantiated type. Trying to change the variance of a particular position (i.e. making a function argument covariant) will not succeed.

Variance annotations are only used during nominal inference between two types, not structural inference. This means that writing an incorrect variance annotation will generate surprising or wrong results in many cases. Again, to produce consistent and correct results, you must write a variance annotation that would match the structurally-measured variance. Variance annotations which don’t match the structural variance will produce surprising results in many cases.

variance annotations only affect relations between two direct instantiations of the same generic

See https://github.com/microsoft/TypeScript/issues/53798 and https://github.com/microsoft/TypeScript/pull/48240#issuecomment-1066527593

Another workaround for this is to explicitly reference the type parameter in dummy fields. This is the pattern taken in my TypeCheck library to enable it to control variance before TypeScript 4.7.

Since variance annotations only affect relations between two direct instantiations of the same generic (there are issues about this), if you need to enforce variance even at the structural level, you’ll need to keep doing it that way.

https://github.com/microsoft/TypeScript/wiki/FAQ#why-doesnt-type-inference-work-on-this-interface-interface-foot--

Your type Brand is empty, there’s no member to infer T from. You shouldn’t have empty types or unused type arguments.

Duplicate of #40796.

it can help when the compiler is unable to do inference efficiently or accurately

This kind of language, and the similar implication throughout, is what worries me. The whole reason we’re here is that you 1) started with an unobserved type parameter, 2) got something that appeared to be working by marking it with a VA, and then 3) got surprised when a structural inference didn’t capture the behavior of the VA’d type parameter.

We need to be encouraging people to not do step 2, otherwise they’ll come here at step 3 and say that they were just following instructions and that the behavior at step 3 is a “bug” when it’s really an incorrect use of a VA.

You should always write the same variance that would be structurally measured by TypeScript (modulo limitations).

Usage suggestions are good, but we need a spec. What do variance annotations do? Do they do nothing by add compile errors if they disagree with that the compiler infers? Can they only restrict/constrain access types or can they replace the inferred variance entirely, allowing both restricting and relaxations of use of the type? Is their purpose to be an error at the declaration site if the annotation is wrong, or is getting it wrong undefined behavior?

Also, to follow this usage guideline, I have to know “the variance that would be structurally measured by TypeScript (modulo limitations)”. How do they interact with mapped types? Is that interaction with mapped types a TypeScript working as intended or one of the “limitations”? How do they works with extends clauses and is that a “limitation” or not? Without those answers, following that usage guidance isn’t possible.

That means I need a hard answer on which of the behaviors around variance are limitations and which are not. I do not think this is clearly documented.

Personally I find the variance rules TypeScript uses to be frequently incorrect (I wrote and use a library to mitigate this since I kept having real issues with it). Thats using soundness as my definition of correct: Its clearly wrong without strict function checks, and even with them its unsound for properties that are functions (when it infers bivariant), its unsound when it doesn’t have enough information (Types are used in whats TS doesn’t see as members, like by other Types with infer) its unsound when it deletes private fields etc. Since these seem to be by design its not clear what “would be structurally measured by TypeScript (modulo limitations).”

What I want to use variance annotations for is three things:

  1. To give me errors/warnings if the TypeScript won’t/can’t make my type have the desired variance. (My assumption is that they worked equivalent to adding an invisible field of the proper type to cause the variance, and would compile error if this didn’t result in the variance requested due to other fields being more restrictive" apparently I was wrong?).
  2. Communicate the variance to other developers for maintainability and API usability purposes.
  3. Give errors to users of the types if they try to use them in a way that disagrees with the actual desired variance.

To me how it apparently works sounds like its the opposite of #1? Since to use it you have to confidently know the variance TypeScript (modulo limitations) would have gotten and anyone changing types in the codebase has to ensure they update all variance annotations they might have transiently impacted)? Thus it seems like adding the annotations adds additional risk for bad behavior to crop up later when the types are modified if someone who does so doesn’t know to update the variance annotation. If thats really whats going on here thats very unlike other similar languages (ex: C#'s use of them), and unlike most other typing in TypeScript (this makes the code maintaince less likely to cause runtime issues that go unnoticed by the compiler).

nominal inference between two types, not structural inference.

Since these cases apparently differ in behavior in important ways, is when each of these happens, and any other ways they differ documented anywhere? Also are these difference included in the “limitations” above (and which version is the limited one? I’m guessing the structural case?)