What you are looking for are existential types. While TypeScript does not currently support existential types directly(nor do most languages with generic types), there are indirect ways to get it done. There has been this discussion on it on the TypeScript GitHub repo, but it hasn't seen much activity in the last year.
The thing is generics in TypeScript are universal, meaning if I define a function as <T>function(i: T) {...} it has to work for all possibles types T.
Arrays like the ones you need require existential generic types. This means that for the same function <T>function(i: T) {...}, the function definition decides a set of types it works for, and when you call the function, you need to account for all possible types. Yeah, it is a bit hard to wrap your head around.
The above text in italics is a paraphrasis from this post by jcalz.
You can implement existential generics in TypeScript with callbacks. Here's your code, rewritten to allow your heterogeneous array.
This is your Item type:
type Item<T> = {
value: T;
fn: (value: T) => void;
}
Now we make an existential generic version for Item:
type ExistentialItem = <U>(callback: <T>(item: Item<T>) => U) => U;
No ExistentialItem is the type of function that takes a callback and returns the result of the callback. The callback takes an Item of type T and returns a result. Note that the type of ExistentialItem does not depend on the type of Item anymore. You'll want to convert all your Items to ExistentialItems, so write a helper for that.
const getExistentialItem = <T>(item: Item<T>): ExistentialItem => {
return (callback) => callback(item);
}
// The compiled JS for the sake of simplicity
const getExistentialItem = (i) => {
return (cb) => cb(i);
};
Now you can use the ExistentialItem to type your heterogeneous array as:
const arr: ExistentialItem[] = [
getItem({
value: 1,
fn: (n) => {
console.log(n.toString())
}
}),
getItem({
value: '1',
fn: (n) => {
console.log(n.padStart(10, '1'))
}
}),
]
All the type checks pass for this array. The caveat is that using this ExistentialItem array is significantly more work, at least in my opinion.
Let's say you want your useItems to return an array of all the keys of the objects in the array. Instead of writing this like you normally would if it was a homogenous array:
const useItems = (items: Item<number>[]) => {
return items.map(i => i.key)
}
You have to first call the ExistentialItem.
const useItems = (items: ExistentialItem[]) => {
return items.map(cb => cb(i => i.key))
}
Here we are passing the callback, in which we decide what to do with the item, instead of just using the item directly.
It's up to you to decide whether this added code complexity is worth having type-checking on heterogeneous arrays. If you know the size of the array, you can use [Item<number>, Item<string>], which is an ok solution, but then pushing to the array or trying to access indices greater than 2 will cause issues. Or you can use any, and use type assertions to get type checking again. Not a good solution IMO.
So let's just hope that existential types are implemented into TS natively at some point.
The whole code again, just for the sake of completion:
type Item<T> = {
key: T
fn: (val: T) => void
}
type ExistentialItem = <R>(cb: <T>(item: Item<T>) => R) => R;
const getExistentialItem = <T,>(i: Item<T>): ExistentialItem => {
return (cb) => cb(i);
}
const arr: ExistentialItem[] = [
getExistentialItem({
key: 1,
fn: (n) => {
console.log(n.toString())
}
}),
getExistentialItem({
key: '1',
fn: (n) => {
console.log(n.padStart(10, '1'))
}
}),
]
const useItems = (items: ExistentialItem[]) => {
return items.map(item => item(i => i.key))
}
Check also this answer and this answer to get a better explanation of universal and existential types.