Reputation: 1745
type Item<T> = {
func: (prop: T) => void;
};
type WrappedItem<T> = {
item: Item<T>;
config: T;
};
type Collection<T = Record<string, unknown>> = {
wrappedItems: WrappedItem<T>[];
};
type Foo = {
str: string;
num: number;
};
const foo: Foo = {
str: 'abc',
num: 123,
};
const myItem: Item<Foo> = {
func: (foo: Foo) => {
console.log(foo);
},
};
const myCollection: Collection = {
wrappedItems: [{ item: myItem, config: foo }], //ERROR: 'Item<Foo>' is not assignable to type 'Item<Record<string, unknown>>'
};
I would like to be able to specify the collection type such that the requirement is just that I have an array of valid wrappedItems. I don’t care what the generic type T is for each of those wrapped items. They could all be different. I just want to ensure they are all valid (so that the T for item matches the type of config).
The above implementation gets a type error because Record<string, unknown>
is not compatible with Foo
.
Type 'Item<Foo>' is not assignable to type 'Item<Record<string, unknown>>'.
Type 'Record<string, unknown>' is missing the following properties from type 'Foo': str, num
if i switch the default type to any
, it loses type safety and the config value does not need to match the item.
and setting it to unknown
also doesn't work, as unknown
is not assignable to Foo
while I can be explicit about the Foo generic type of the collection, it seems to run into issues if I have more than one type (and I’d rather not have to be explicit anyway)
type Bar = {
blah: string;
};
const bar: Bar = {
str: 'abc',
num: 123,
};
const myItem2: Item<Bar> = {
func: (bar: Bar) => {
console.log(bar);
},
};
const myCollection: Collection<Foo | Bar> = {
wrappedItems: [
{ item: myItem, config: foo },
{ item: myItem2, config: bar },
],
};
that has issues
Type 'Item<Foo>' is not assignable to type 'Item<Foo | Bar>'.
Is there a way to get this working? really I’d like Collection to not even be a generic. I don’t care about the types it has, just that they are valid.
Upvotes: 3
Views: 371
Reputation: 70367
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:
T
T
as argumentSince 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.
Upvotes: 3
Reputation: 329943
Conceptually what you are looking for is something called "existential types", as requested in microsoft/TypeScript#14466 but not directly supported in TypeScript. An existential type is a different sort of generic type parameter where you just know that it exists but not what specific type it is. When you say "I don’t care about the types it has, just that they are valid", that's what you're asking for. If there were existential types, you'd say something like Collection<exists T>
and then you could use Collection
without needing to specify a type parameter.
There are ways to emulate existential types in TypeScript by using generic-functions-which-operate-on-generic-functions, since (and I'm not explaining this here), a generic function's type parameter looks like an existential type to the function implementer. It's a bit clunky to use.
But instead of trying to simulate/emulate existential types, I think your best option in this case might be to represent your Collection
as mapping a tuple. So if wrappedItems
is like [WrappedItem<Foo>, WrappedItem<Bar>
, then you have a Collection<[Foo, Bar]>
. The definition would look like this:
type Collection<T extends any[]> = {
wrappedItems: [...{ [I in keyof T]: WrappedItem<T[I]> }]
};
Then, to free yourself from having to write out such generic tuples, you can make a helper function to make the compiler infer the type for you:
const asCollection = <T extends any[]>(coll: Collection<T>) => coll;
The function asCollection()
is just an identity function; it returns its input. But it will make sure that what is passed in conforms to a Collection<T>
for some T
, and if not, you'll get an error:
const goodCollection = asCollection({
wrappedItems: [
{ item: myItem, config: foo },
{ item: myItem2, config: bar },
],
}); // okay
// const goodCollection: Collection<[Foo, Bar]>
const badCollection = asCollection({
wrappedItems: [
{ item: myItem2, config: foo }, // error!
//~~~~ <-- Type 'Item<Bar>' is not assignable to type 'Item<Foo>'.
{ item: myItem2, config: bar },
],
});
In the goodCollection
case, the compiler infers the type Collection<[Foo, Bar]>
without forcing you to write it. In the badCollection
case, the compiler infers Collection<[Foo, Bar]>
and catches the mismatch in the first element of the array. In neither case are you writing out the generic type parameter, so if you squint and look from a distance, it's almost like using an existential type.
Upvotes: 2