2

I'm trying to make this work

interface ObjectPool<Ids, T> {
  pool: {
    [K in Ids]: T<K>;
  };
};

interface Player<Id> {
  id: Id;
}

let playerPool: ObjectPool<0 | 1 | 2, Player>;

so that

playerPool[0].id === 0;
playerPool[1].id === 1;
playerPool[2].id === 2;
// playerPool[3] error

but typescript says I need a generic parameter at Player in let playerPool: ObjectPool<0 | 1 | 2, Player>; so I tried let playerPool: ObjectPool<0 | 1 | 2, Player<_>>; but that doesn't work too

SharedRory
  • 246
  • 6
  • 17
  • 2
    Unfortunately, typescript ATM does not support higher order generics, i.e. what you're trying to do with `T`. See: https://stackoverflow.com/a/60008205/2967697 – zecuria Dec 12 '21 at 22:49
  • 1
    So your ObjectPool will only ever have a handful of items? If no, this approach doesn't scale. If yes, then this design is overkill. Can you explain the goal? – Inigo Dec 12 '21 at 23:43
  • @Inigo I am creating a declaration file for a game. The player pool consists of 81 objects. The reason I use a generic type is because it can be used for other entities. The wall pool has 2000 objects. – SharedRory Dec 13 '21 at 00:32
  • So you intend do do something like `ObjectPool<0 | 1 | 2 | ... | 1999 | 2000, Wall>`? This is why I ask. That's not what static type checking is for. It doesn't make sense. – Inigo Dec 13 '21 at 01:44
  • What does `Wall` look like? If the only generic piece is the `id` property and they all share it, it's possible that you could do something a little different like [this](https://tsplay.dev/WG69om) to get it to work. But I agree with others that `0 | 1 | 2 | ... | 1999` is not going to be fun for you. Even `0 | 1 | 2` is probably not great. If you intend to loop over these things via numeric index, it'll be hard to treat the compiler that such an index will be of the narrowed union-of-numbers type instead of `number`. – jcalz Dec 13 '21 at 02:20
  • I'm happy to write the above code up as an answer if it meets your needs, subject to caveats. If there's something I'm missing about the question, though, please elaborate, preferably by [edit]ing the [mre] to demonstrate unsatisfied use cases. – jcalz Dec 13 '21 at 02:22
  • @jcalz What you did with `{ id: K } & T }` is clever and works so do write is as an answer. I had already researched compile time generation of a range of numbers to use for types but nothing worked for the larger numbers, such as 2000. I understand what you guys are saying that it doesn't scale well but a 1 liner in js can generate all of it for me and then my ide can format it – SharedRory Dec 13 '21 at 03:43
  • @jcalz you are clearly a wizard with types, but does doing this make sense in any way (honest question)? Is it an abuse of static typing? Given TS being Turing Complete, we're gonna have to come up with meta static type checking soon. – Inigo Dec 13 '21 at 05:35

1 Answers1

1

If you write T<K>, then T needs to be some particular type operation (e.g., type T<K> = ... or interface T<K> { ... or class T<K> ...). There's no way to write T<K> where T is a generic type parameter. That would require so-called higher-kinded types, of the sort requested in microsoft/TypeScript#1213, and TypeScript has no direct support for that.

You could step back and try to think of exactly what you want to do, and if there's any way to represent it without needing higher kinded types. If all you want is for ObjectPool<P, T> to have all property keys in P, and for each such key K, you want the property value to have an id property equal K in addition to some other properties specified by T, then you can separate out the id part in the definition so that T is just a regular type. For example:

type ObjectPool<P extends PropertyKey, T> =
  { [K in P]: { id: K } & T };

Now you could define Player without making it generic:

interface Player {
  id: number,  // <-- you don't necessarily need this anymore
  name: string,
}

And now an ObjectPool<0 | 1 | 2, Player> should behave as desired:

function processPlayerPool(playerPool: ObjectPool<0 | 1 | 2, Player>) {
  playerPool[0].id === 0;
  playerPool[1].id === 1;
  playerPool[2].id === 2;
  playerPool[2].name;
  playerPool[3] // error
}

You can then define other types to use instead of Player and use them too:

interface Wall {
  location: string
  orientation: string
}

function processSmallWallPool(wallPool: ObjectPool<0 | 1, Wall>) {
  wallPool[0].location // okay
  wallPool[0].id === 0; // okay
  wallPool[1].orientation // okay
  wallPool[2] // error
}

You mentioned in a comment that you have 2,000 Wall objects in the pool. That's a lot of elements to put in a union, but sure, you could do it (code generation is going to be easier than trying to convince the compiler to compute it):

// console.log("type WallIds = " + Array.from({ length: 2000 }, (_, i) => i).join(" | "));
type WallIds = 0 | 1 | 2 | 3 | 4 | // ✂ SNIP! 
  | 1995 | 1996 | 1997 | 1998 | 1999

And then ObjectPool<WallIds, Wall> will also behave as desired:

function processWallPool(wallPool: ObjectPool<WallIds, Wall>) {
  wallPool[214].location
  wallPool[100].id // 100
  wallPool[1954].orientation
  wallPool[2021] // error
}

Please note though that the compiler really can't do much analysis on a union of numeric literals. You might have more trouble than you expected with this. If you try to loop over the elements of wallPool with a numeric index i, the compiler will complain:

  for (let i = 0; i < 2000; i++) {
    wallPool[i] // error! 
  //~~~~~~~~~~~ 
  // No index signature with a parameter of type 'number' 
  // was found on type 'ObjectPool<WallIds, Wall>'
  }

It has no idea that i is guaranteed to be a value of type WallIds in that loop. It infers number, and you can't index into wallPool with any old number. It needs to be a value of the WallIds union. You could assert that i is a WallIds:

  for (let i = 0 as WallIds; i < 20000; i++) {
    wallPool[i] // no error, but, 20000 is an oopsie
  }

but, as shown above, you run into the problem that the compiler can't understand that i++ might make i no longer a valid WallIds, as explained in this comment of microsoft/TypeScript#14745.

If you're only ever going to be indexing into WallPool with a numeric literal value, like wallPool[123] or wallPool[1987], then that's fine. But as soon as you start storing and manipulating indices of type number, you will likely hit a roadblock with this approach. It might still be worth it to you, but it's important to be aware of it.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 2
    Thank you for turning your comment into an extensive answer. Everything you said in the second half of your answer is completely valid. The problem is, I don't own the game. I am writing a declaration file for it and so it has to be as the developers made it. The code I have to work with is also obfuscated but the variable names dont change between updates – SharedRory Dec 13 '21 at 14:08
  • And how bad would it be if you just wrote `type ObjectPool = Record` or `type ObjectPool = Record`? I suspect it comes down to how often you index into an object pool with a numeric literal (so a developer writes `pool[123]`) as opposed to a nonliteral expression (like `pool[i]` where `i` is `number` or even `keyof typeof pool`). – jcalz Dec 13 '21 at 14:23
  • " I suspect it comes down to how often you index into an object pool with a numeric literal (so a developer writes pool[123]) as opposed to a nonliteral expression (like pool[i] where i is number or even keyof typeof pool)." The game has a global variable with the players id so its not uncommon to see `x.pool[id]` everywhere. Also, the game code is structured in such a way that to obtain a player you need to somehow get an id and then find the player in the pool – SharedRory Dec 13 '21 at 17:11