14
interface Instruction {
  promise: Promise<unknown>,
  callback?: ($html: JQuery, data: unknown ) => void
}

const arr: Instruction[] = [
  { promise: Promise.resolve({ foo: 'bar' }), callback: ($html, data) => console.log(data.foo) },
  { promise: Promise.resolve({ bar: 'foo' }), callback: ($html, data) => console.log(data.bar) }
];

Given the above, I'd like TypeScript to recognise that the data parameter in the callback function is of the same type as the resolution of the Promise.

If it was stand alone, I could do:

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

But then how would I define the array, where T can mean something different on each line?

kim3er
  • 6,306
  • 4
  • 41
  • 69
  • In your actual code, will `arr` be an immutable array of a length and type known at compile-time? – Aplet123 Dec 03 '20 at 15:42
  • @Aplet123, no in the real code the array is constructed within a bigger class, using `push`. – kim3er Dec 03 '20 at 15:44
  • 1
    Does this answer your question? [How do you define an array of generics in TypeScript?](https://stackoverflow.com/questions/51879601/how-do-you-define-an-array-of-generics-in-typescript) – eddiewould May 12 '22 at 21:23

2 Answers2

19

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • @jcalz: This is amazing thank you. I think I get what's going on here, but I'm struggling with this syntax, ``. Why the comma? It appears to be declaring an unused type parameter, but I can't fathom why. – kim3er Dec 03 '20 at 17:03
  • 1
    The trailing comma doesn't introduce a new parameter. It is just to stop the compiler from getting angry in the case that you are using tsx/jsx. (in tsx/jsx mode, the compiler sees `` in that position as a tag and complains). You can remove the comma if that compiles for you. – jcalz Dec 03 '20 at 17:06
  • 1
    Oh wow, okay! I can't decide if I feel more or less stupid now. Thanks! – kim3er Dec 03 '20 at 17:12
2

Maybe it will help You:

type JQuery = 'jquery'

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

const builder = <T,>(arg: T): Instruction<T> => ({ promise: Promise.resolve(arg), callback: ($html, data) => data })

const arr = [
  builder({ foo: 'bar' }),
  builder({ bar: 'foo' }),
];

If this array is mutable, things get complicated.

UPDATE

You have to add extra comma <T,>, becouse without , TS thinks that You are trying to use some sort of react jsx syntax. You should use this hack only with arrow function. It works as expected with function. Instead of extra comma, You can use <T extends unknown> or <T extends {}>

  • Thanks @capain-yossarian! It hadn't occurred to me to wrap the instruction like that. Could you explain what is happening with `` though? I've scoured the internet but can't fathom it. Is it some sort of shortcut? – kim3er Dec 03 '20 at 17:06