Let's learn about variance. Whenever you have a generic type argument, such as your T
, it can be
If it happens to be neither, we call it "invariant", and if it happens to be both, we call it "bivariant". Many languages, like Scala and C#, expect you to annotate your type variables explicitly and specify the variance. Other languages, like Typescript in particular, will simply allow the maximum variance possible.
A covariant type parameter can be upcasted safely. Let's say that we have
interface Foo<T> {
foo(x: number): T
}
If I have a example: Foo<string>
, then I know that example.foo
returns a string
. But it's also completely correct to say that example.foo
returns an object
, so example: Foo<object>
is also valid. That is, Foo<string>
is a subtype of Foo<object>
since string
is a subtype of object
. This type argument is covariant: the subtype relation goes the same direction on Foo
as it does on T
.
Now, let's suppose we instead have
interface Foo<T> {
foo(x: T): number
}
Now, if I have example: Foo<object>
, then I know example.foo
takes an object
as argument. It can take any object at all. In particular, it can take a string
(since strings are objects), so example: Foo<string>
is correct. That is, we started with string
a subtype of object
and concluded that Foo<object>
is a subtype of Foo<string>
. The direction of the subtype relation changed. This is called contravariance.
And that's also exactly how you determine the variance of a type. If a type parameter is only used in argument position, it can be contravariant. If it's only used in result position, it can be covariant. If it's used in both, it's invariant, which means Foo<string>
and Foo<object>
are not related to each other by subtyping. If it's not used at all, it's bivariant, which means Foo<string>
and Foo<object>
are equivalent (this basically means you don't use the type parameter at all; we call that a phantom type).
Now, let's look at your example.
type Item<T> = {
func: (prop: T) => void;
};
type WrappedItem<T> = {
item: Item<T>;
config: T;
};
Item
is contravariant in T
, as T
only appears as an argument, never a result. WrappedItem
, on the other hand, has config: T
. Now, config
is not a function; it's a variable, and it's not read-only. Effectively, there's two things we can do:
- We can get the value, which involves returning a
T
- We can set the value, which involves passing a
T
as argument
Since we use it in both argument and result position, it's invariant, and thus T
has no subtyping relations with respect to WrappedItem
. Even if we made it readonly
, it would still appear in argument position in Item<T>
and hence be invariant. As written, WrappedItem
is doomed to be invariant in T
If it was covariant in T
, then your Foo | Bar
example would work, since the union type is defined to be the smallest supertype. Similarly, we could use object
, the top type, as our T
.
If it was contravariant in T
, then we could use Foo & Bar
, since the intersection is defined to be the largest subtype. Similarly, we could use never
, the bottom type, as a catch-all.
But it's neither. The goal you posed is "I want T
to be some type, but I don't care which" for each element of the Collection
. This has a name; it's called existential quantification, and unfortunately Typescript does not support this feature. You can encode them using the technique indicated in that answer or this Gist, but honestly the simpler answer is probably just to exploit any
but provide a sensible interface. My recommendation is to not publicize your collection's internals but instead provide an add
method which is universally quantified in T
.
type Item<T> = {
func: (prop: T) => void;
};
type WrappedItem<T> = {
item: Item<T>;
config: T;
};
class Collection {
wrappedItems: WrappedItem<any>[] = [];
add<T>(item: WrappedItem<T>) {
this.wrappedItems.push(item);
}
};
const myCollection: Collection = new Collection();
myCollection.add({ item: myItem , config: foo });
myCollection.add({ item: myItem2, config: bar });
Internally, we factor through any
to circumvent a limitation of the type system (namely, existential types), but our public interface still checks that a valid T
exists, since that's the declared type of the add
method.