如何确保嵌套对象中的字符串引用对象键之一

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

如何使打字稿将字符串类型限制为仅作为同一对象中的键?下面是一个简化的示例。我尝试了各种 K extends string 类型的逻辑,但我很困惑。

{
  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
    },
  }
}

我的用例是我正在构建一个游戏 - 这个例子被简化了,但基本上每个玩家都可以瞄准另一个玩家 - 由 isTargeting 的值表示 - 要么是一个字符串,我想要强制成为对象的键之一,例如示例中的 uniqueId1uniqueId2,或者 null - 他们没有针对任何人。

TypeScript 中没有特定 类型以这种方式工作;如果您静态地知道 players 中的键列表 K,(例如 "uniquePlayerId1" | "uniquePlayerId2")对于 players 中任何特定的键选择,那么您可以定义特定类型 PlayersType<K> 就好像

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

但是因为你事先不知道K,你想定义SomePayersType等于PlayersType<K>对于some K。这种类型称为 existentially quantified generic type and, although it's been requested (see microsoft/TypeScript#14466),TypeScript 不直接支持此类类型。与大多数具有泛型的语言一样,TypeScript 只有 通用量化的 泛型类型。如果我们有存在量化的泛型,你可能会说:

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

但是现在你不能。有一些方法可以用通用类型模拟存在类型,但它们很麻烦,而且不一定适合您的用例。


相反,您可以做的是采用 PlayersType<K> 并创建一个通用的辅助身份函数,该函数从 passed-in 值中为您推断出 K。它可能看起来像这样:

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

但不幸的是,这并不完全符合您的要求;编译器实际上从 isTargeting 值而不是 players 的键推断出 K,这意味着它将拒绝有效参数:

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

我们想告诉编译器不要从 isTargeting 推断出 K;也就是说,按照 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 constrainedK 中的要求,使 isTargeting 成为 Knon-inferential 类型参数用法 ,并使用 K 键和 LisTargeting:

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

现在让我们使用它:

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

看起来不错;编译器推断 playersPlayersTaype<"uniquePlayerId1" | "uniquePlayerId2"> 类型。让我们看看如果我们传递一些不好的东西会发生什么:

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

在这里我们看到了预期的编译器错误;由于没有名为 "oopsie" 的密钥,因此 isTargeting 属性 无法命名为 "oopsie".

好了。您没有使用特定的 SomePlayersType 类型,而是使用 PlayersType<K> 类型从值中推断 K 。这是一种存在类型的“half-way”,可能足以满足您的需求。你可能会发现自己携带了这个额外的类型参数,所以你可能想在你控制的代码中使用更简单的东西,比如 PlayersType<string>,并且只对不太受信任的代码强制执行 K 的推断:

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

function innerLibraryCode(players: PlayersType<string>) {

}

Playground link to code