Home > Software design >  How do I ensure strings within a nested object refer to one of the objects keys
How do I ensure strings within a nested object refer to one of the objects keys

Time:02-28

How do I make typescript restrict a string type to only being keys within the same object? A simplified example is below.. I've tried a variety of K extends string type logic but am stumped.

{
  players: {
    uniquePlayerId1: {
       isTargeting: 'uniquePlayerId2' // should only be able to be `uniquePlayerId1` or `uniquePLayerId2` or any other keys of the `players` object
    },
    uniquePlayerId2: {
      isTargeting: 'uniquePlayerId1', // should only be able to be `uniquePlayerId1` or `uniquePLayerId2` or any other keys of the `players` object
    },
  }
}

My use case is I'm building a game - the example is simplified, but essentially each player can target another player - indicated by the value of isTargeting - either a string, which I'd like to enforce being a one of the keys of the object, e.g. uniqueId1 or uniqueId2 in the example, or null - they aren't targeting anyone.

CodePudding user response:

There is no specific type in TypeScript that works this way; if you statically knew the list of keys K in players, (say "uniquePlayerId1" | "uniquePlayerId2")for any particular choice of keys in players, then you could define a specific type PlayersType<K> in terms of it like

type PlayersType<K extends string> =
  { players: Record<K, { isTargeting: K }> }

But since you don't know K in advance, you want to define SomePayersType which is equal to PlayersType<K> for some K. This sort of type is called an existentially quantified generic type and, although it's been requested (see microsoft/TypeScript#14466), TypeScript does not directly support such types. Like most languages with generics, TypeScript only has universally quantified generic types. If we had existentially quantified generics you might be able to say:

// not valid TypeScript, don't try this:
type SomePlayersType = <exists K extends string> PlayersType<K>

But for now you can't. There are ways to emulate existential types with universal types but they are cumbersome and not necessarily warranted for your use case.


Instead, what you could do is take the PlayersType<K> and make a generic helper identity function which infers K for you from the passed-in value. It might look like this:

const asPlayers = <K extends string>(
  obj: PlayersType<K>
) => obj;

But unfortunately this doesn't quite work how you want; the compiler actually infers K from the isTargeting values and not from the keys of players, meaning that it will reject valid arguments:

const players = asPlayers({
  players: {
    uniquePlayerId1: {
      isTargeting: 'uniquePlayerId2' // error?!
    },
    uniquePlayerId2: {
      isTargeting: 'uniquePlayerId2'
    },
  }
})

We'd like to tell the compiler not to infer K from isTargeting; that is, make isTargeting a non-inferential type parameter usage of K, as requested in microsoft/TypeScript#14829. There are different ways to get such behavior; in this case we can just add a new type parameter L which is constrained to K, and use K for the keys and L for isTargeting:

const asPlayers = <K extends string, L extends K>(
  obj: { players: Record<K, { isTargeting: L }> }
): PlayersType<K> => obj;

Now let's use it:

const players = asPlayers({
  players: {
    uniquePlayerId1: {
      isTargeting: 'uniquePlayerId2'
    },
    uniquePlayerId2: {
      isTargeting: 'uniquePlayerId1'
    },
  }
})
/* const players: PlayersType<"uniquePlayerId1" | "uniquePlayerId2"> */

That looks good; the compiler is inferring that players is of type PlayersTaype<"uniquePlayerId1" | "uniquePlayerId2">. Let's see what happens if we pass in something bad:

const badPlayers = asPlayers({
  players: {
    uniquePlayerId1: {
      isTargeting: "oopsie" // error
      // Type '"oopsie"' is not assignable to 
      // type '"uniquePlayerId1" | "uniquePlayerId2"'
    },
    uniquePlayerId2: {
      isTargeting: 'uniquePlayerId1'
    }
  }
})

Here we see the desired compiler error; since there is no key named "oopsie", the isTargeting property cannot be named "oopsie".

So there you go. Instead of having a specific SomePlayersType type, you have a PlayersType<K> type where you infer K from the value. This is sort of "half-way" to an existential type, and might be good enough for your needs. You might find yourself carrying around this extra type parameter, so maybe you want to use something easier like PlayersType<string> inside code you control, and only enforce the inference of K for less trusted code:

function publicFacingCode<K extends string>(players: PlayersType<K>) {
  innerLibraryCode(players);
}

function innerLibraryCode(players: PlayersType<string>) {

}

Playground link to code

  • Related