Defining an array of differing generic types in TypeScript

This is practically the canonical use case for existential generic types, which are not directly supported in TypeScript (neither are they directly supported in most languages with generics, so it’s not a particular shortcoming of TypeScript). There is an open feature request, microsoft/TypeScript#14466, asking for this, but it is not part of the language as of TS4.1.

Generics in TypeScript are “universal”, meaning that when I say class Foo<T> {...} I mean that it works for all possible type parameters T. That lets the consumer of a Foo<T> specify the value for T and do what they want with it, while the provider of Foo<T> needs to allow for all possibilities.

Heterogeneous collections like the one you are trying to describe require “existential” generics. In some sense you want interface Instruction<exists T> {...} to mean that there is a type parameter T for which it works. Meaning that the provider of an Instruction could specify the value for T and do what they want with it, while the consumer of an Instruction needs to allow for all possibilities.

For more information about universally-vs-existentially quantified generics, see this SO question and answer.


While there is no direct support for existentials in TypeScript, there is indirect support. The difference between a universal and an existential has to do with who is looking at the type. If you switch the role of producer and consumer, you get existential-like behavior. This can be accomplished via callbacks. So existentials can be encoded in TypeScript.

Let’s look at how we might do it for Instruction. First, let’s define Instruction as a universal generic, the “standalone” version you mentioned (and I’m removing the JQuery dependency in this code):

interface Instruction<T> {
    promise: Promise<T>,
    callback?: (data: T) => void
}

Here’s the existential encoding, SomeInstruction:

type SomeInstruction = <R>(cb: <T>(instruction: Instruction<T>) => R) => R;

A SomeInstruction is a function that calls a function that accepts an Instruction<T> for any T and returns the result. Notice how SomeInstruction does not itself depend on T anymore. You might wonder how to get a SomeInstruction, but this is also fairly straightforward. Let’s make a helper function that turns any Instruction<T> into a SomeInstruction:

const someInstruction = <T,>(i: Instruction<T>): SomeInstruction => cb => cb(i);

Finally we can make your hetereogeneous collection:

const arr: SomeInstruction[] = [
    someInstruction({ 
      promise: Promise.resolve({ foo: 'bar' }), 
      callback: (data) => console.log(data.foo) 
    }),
    someInstruction({ 
      promise: Promise.resolve({ bar: 'foo' }), 
      callback: (data) => console.log(data.bar) 
    })
]

That all type checks, as desired.


Actually using a SomeInstruction is a bit more involved than using an Instruction<T>, since it takes a callback. But it’s not terrible, and again, allows the T type parameter to appear in a way that the consumer doesn’t know what the actual T type is and therefore has to treat it as any possible T:

// writing out T for explicitness here
arr.forEach(someInstruction => someInstruction(<T,>(i: Instruction<T>) => {    
    i.promise.then(i.callback); // works
}))

// but it is not necessary:
arr.forEach(someInstruction => someInstruction(i => {    
    i.promise.then(i.callback); // works
}))

Nice.


There are other workarounds, but an existential is what you really want. For completeness, here are some possible workarounds that I will mention but not implement:

  • give up on type safety and use any or unknown and use type assertions to get back the types you want. Blecch.

  • Use a mapped tuple type where you convert a type like [T1, T2, T3] to the corresponding [Instruction<T1>, Instruction<T2>, Instruction<T3>] type. This won’t work with push() though, so you’d need to work around that somehow too.

  • Refactor Instruction so as not to need/expose generics. Whatever the consumer plans to do with an Instruction<T> it has to be independent of T (e.g., I can write i.promise.then(i.callback) but I can’t do much else, right?), so make an Instruction generic function which requires a valid promise-and-callback pair to create, and returns something non-generic with whatever functionality you need and that’s it. In some sense this is a “stripped-down” existential that does not allow the consumer to access the internal promise-callback pair separately.


Playground link to code

Leave a Comment