A fundamental feature of TypeScript's type system is that it's structural and not nominal. This is in contrast to type systems like Java's where interface A {}
and interface B {}
are considered different types because they have different declarations. In TypeScript, when types A
and B
have the same structure then they are the same type, regardless of their declarations. TypeScript's type system is structural.
Except when it's not.
Comparing two types structurally can be expensive for the type checker. Imagine two deeply nested or even recursive types X
and Y
, and the compiler has to check whether X
is a subtype of Y
because you are trying to assign a value of type X
to a variable of type Y
. The compiler needs to start checking each property of Y
with that of X
, and each subproperty, and each subsubproperty, until it finds an incompatibility or until it reaches a point where it can stop checking (either it reaches the end of the tree structure or it finds something it's already checked). If this were the only type comparison method available to the TypeScript compiler, then it would be very slow.
So instead the compiler sometimes takes a shortcut. When you define a generic object type, the compiler measures the type's variance (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ) from its definition, and marks it as such for later use. For example in:
interface Cov<T> { f: () => T }
interface Contrav<T> { f: (x: T) => void; }
interface Inv<T> { f: (x: T) => T; }
interface Biv<T> { f(x: T): void; }
the compiler marks those as covariant, contravariant, invariant, and bivariant, respectively. That makes sense with the type system:
declare let ca: Cov<"a">
declare let cs: Cov<string>;
cs = ca; // okay
ca = cs; // err
declare let cna: Contrav<"a">
declare let cns: Contrav<string>;
cns = cna; // err
cna = cns; // okay
declare let ia: Inv<"a">
declare let is: Inv<string>;
is = ia; // err
ia = is; // err
declare let ba: Biv<"a">
declare let bs: Biv<string>;
bs = ba; // okay
ba = bs; // okay
When the compiler assigns those markers to a complicated generic type F<T>
, a lot of time can be saved when comparing F<X>
to F<Y>
. Instead of having to plug X
and Y
into F
and then compare the results, it can just compare X
and Y
and use the variance marker on F
to compute the desired result. In such cases, the compiler can just treat F<T>
as a black box.
If you have another complicated generic type G<T>
, though, the compiler can't use a shortcut to compare F<X>
to G<Y>
, since G
could be completely unrelated to F
. Even if it turns out that F
and G
's definitions are the same, the compiler wouldn't know that unless it compares those definitions, meaning the black box needs to be opened. A full structural comparison is the only option here.
So here we have a situation where F<X> extends G<Y>
results in one code path for the compiler, but F<X> extends F<Y>
results in another code path. That sure looks like the compiler is comparing those types nominally, based on their declarations.
But such a difference is unobservable, right? Because the type system is completely sound, right? If the compiler assigns a variance marker, it does so correctly, right? And subtyping is a transitive relationship, so if X extends Y
and Y extends Z
then X extends Z
, and if F<T>
is marked covariant in T
then F<X> extends F<Y>
and F<Y> extends F<Z>
and also F<X> extends F<Z>
, right?
Right?
No, of course not, not completely. TypeScript's type system is intentionally unsound in places where convenience has been considered more important. One such place is with optional properties and optional parameters. The compiler lets you assign a value of type {x: string}
to a variable of type {x: string, y?: number}
, for convenience:
interface X { x: string }
interface YN extends X { y?: string }
let x: X = { x: "" };
let yn: YN = x; // okay, no compiler error
But this is unsafe, because really a missing property definition has a completely unknown
value at runtime:
interface YB extends X { y: boolean }
const yb: YB = { x: "", y: true }
x = yb; // okay, every YB is an X
yn = x; // okay? no compiler error but... wait:
yn.y?.toUpperCase(); // runtime error
So you have a situation where YB extends X
and X extends YN
are true, but YB extends YN
is false.
See microsoft/TypeScript#42479 and the issues linked within for more information.
If you have a type function F<T>
that's marked covariant in T
, then what should happen? Presumably F<YB> extends F<X>
and F<X> extends F<YN>
are true but this can easily be violated if F
is indexing into that optional property and doing something with its type. That's more or less what's happening with your code:
type Entity<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
// ^? type UsingVarianceMarker = true
The compiler thinks Entity
is covariant (or maybe bivariant) and so the result is true
because {ref1: string} extends {}
. But indexing into the ref1
property of {}
is unknown
, and so Entity
really shouldn't be covariant (maybe?):
type Entity2<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingStructural = Entity<{ ref1: string }> extends Entity2<{}> ? true : false
// ^? type UsingStructural = false
Maybe the variance marker is being assigned incorrectly? It's hard to say. Given the unsoundness around optional properties, there is probably no "correct" marker and the compiler just picks something that behaves well in a wide range of real-world situations. That's just how it is sometimes; see microsoft/TypeScript#43608 for example.
So what can be done? Well you could refactor completely, but in cases where you don't like the variance marker assigned by the compiler, you can assign your own (as long as the compiler doesn't see a conflict) using the optional type parameter variance annotations in
(for contravariance), out
(for covariance), or in out
(for invariance). So maybe:
type Entity<in out T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
// ^? type UsingVarianceMarker = false
which makes Entity
behave as if it were invariant in its parameter, and therefore Entity<T> extends Entity<U>
will be false unless T
and U
are the same type. That's not necessarily what you want to do, and it's not true in general, but at least it's a lever you can pull to change this behavior.
Playground link to code