bdwain
bdwain

Reputation: 1745

generic collection type does not take any object

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

Answers (2)

Silvio Mayolo
Silvio Mayolo

Reputation: 70367

Let's learn about variance. Whenever you have a generic type argument, such as your T, it can be

  • Covariant
  • Contravariant

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.

Upvotes: 3

jcalz
jcalz

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.

Playground link to code

Upvotes: 2

Related Questions