字符串或数字类型的打字稿通用键

Posted

技术标签:

【中文标题】字符串或数字类型的打字稿通用键【英文标题】:Typescript generic key of type string or number 【发布时间】:2022-01-03 01:25:43 【问题描述】:

所以我有这些类型:

type car = 
    id: number;
    horsePower?: number;
    date: Date;
;

type book = 
    id: string;
    title?: string;
    date: Date;
;

现在我想创建一个通用函数,它接受horsePowertitle 之类的参数,并在carsbooks 的列表中搜索重复项。所以搜索参数和id的类型可以不同,所以string|number两者都可以。

但是传入的key和value必须是同一类型,string或者number

我试过这种方法:

constructor() 
  const carList: Array<car> = [ id: 111, horsePower: 1337 ];
  this.ifAlreadyExists(carList, 'horsePower', 1337, 'id', 100);

  const bookList: Array<book> = [ id: '222', title: 'Title 1' ];
  this.ifAlreadyExists(bookList, 'title', 'Title 1', 'id', '200');


ifAlreadyExists<T, H extends keyof T, K extends keyof T>(
  data: Array<T>,
  key: Extract<keyof T, string | number>,
  value: Extract<T[K], string | number>,
  idKey: H,
  idValue: T[H]
): boolean 
  if (typeof value === 'string' && typeof key === 'string') 
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key].trim().toLowerCase() ===
          value.trim().toLowerCase()
      ).length === 0
    );
   else 
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key] === value
      ).length === 0
    );
  

复制:https://codesandbox.io/s/angular-11-playground-forked-y9ku0?file=/src/app/app.component.ts

但是当我想进行特定类型的调用时,它会失败:

Property 'trim' does not exist on type 'T[Extract<keyof T, string | number> & string]'.ts(2339)

我如何告诉 typescript,传递的 key 与传递的 value 的类型匹配。那么item[key] 只能是stringnumber 的类型

【问题讨论】:

Please replace/supplement images of code/errors with plaintext versions. 您可能会惊讶地发现item[key] 实际上可能是number? Observe...。编译器在技术上是正确的抱怨。大概你应该只是假设这不会发生and use a type assertion 或be extra careful(不确定如果违反了你想做什么)或其他什么。我很高兴将这些写下来作为答案;但是,如果您仍有未满足的需求,请edit 指定问题。 this 是否满足您的需求?这可以防止有人使用string | number 调用函数,但它仍然需要类型断言,因为编译器无法真正理解item[key]value 彼此相关;见ms/TS#30581。让我知道我是否可以将其写为答案,或者您是否需要其他内容。 糟糕,这有点像我的代码中的拼写错误(我忘了在调用签名中使用H)。 this 对你有用吗?如果是这样,我会把它写下来作为答案。如果仍有不满意的用例,请告诉我,我会尽力解决。 喜欢this?只需确保更新问题中的示例代码(这样答案看起来就不会突然引入undefined),如果有机会,我会很乐意写下答案。跨度> 【参考方案1】:

如果你只想运行它,你可以:

data.filter(
#here-->  (item:any) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key].trim().toLowerCase() ===
          value.trim().toLowerCase()
      ).length === 0

但我认为“任何”都不是最好的解决方案

【讨论】:

这确实消除了 IDE 中显示的错误,但在运行时,item[key] 的类型可能为 number,然后引发错误。 item[key].trim() 在数字上是不可能的【参考方案2】:

您可以使用元组来存储键和值参数。

type car = 
    id: number;
    horsePower?: number;
    date: Date;
;

type book = 
    id: string;
    title?: string;
    date: Date;
;

function foo([key, value]: ["horsePower", number] | ["title", string]) 

    if (key === "horsePower") 
        console.log(value); // will always be a number
    

    if (key === "title") 
        console.log(value); // will always be a string
    


【讨论】:

【参考方案3】:

所以你有一个约束,当你调用ifAlreadyExists()时,你需要value的类型和data[n][key]的类型(对于数字n)要么都可以分配给string,要么两者都可以分配可以分配给number

调用者执行此操作的最简单方法可能是使其成为具有两个调用签名的overloaded 方法,每个调用签名一个:

// call signatures

ifAlreadyExists<K extends PropertyKey, H extends PropertyKey>(
  data: Array<Partial<Record<K, string>> & Partial<Record<H, string | number>>>, 
  key: K, value: string,
  idKey: H, idValue: string | number
): boolean;

ifAlreadyExists<K extends PropertyKey, H extends PropertyKey>(
  data: Array<Partial<Record<K, number>> & Partial<Record<H, string | number>>>, 
  key: K, value: number,
  idKey: H, idValue: string | number
): boolean;

第一个调用签名处理string 情况,第二个签名处理number 情况。拆分为重载可以防止出现 union type 值的奇怪情况,例如 string | number

  this.ifAlreadyExists(
    [ a: Math.random() < 0 ? "hey" : 7 ], // error
    // ~ <-- you want a compiler error here
    "a", "hey", "a", 100
  );

在该示例中,data[0].a 的推断类型类似于string | number,如果您接受它,您最终将接受valuestringdata[0].a 的情况是number,而检查typeof value === 'string' 是不够的检查。在我们在 cmets 中的讨论中,您表示您真的不想检查 data 的每个 item 元素的 typeof valuetypeof item[key],因此最好防止以这种方式调用该方法.


不管怎样,在两个类型参数中可以看到调用签名为generic:K对应key传入的property key,H对应@987654353传入的property key @。从这些参数中,我们可以将data的类型表示为:

Array<Partial<Record<K, string>> & Partial<Record<H, string | number>>>

(对于第一个调用签名)这意味着它将接受任何参数,该参数是一个元素数组,在键 K 处具有可选的 string-valued 属性(对于第二个调用签名,这将更改为 number ) 和一个可选的string-或-number-valued 属性,位于键H。这使我们不必处理与data 的类型相对应的第三个类型参数T。我们可以做到这一点,但这不是必需的(至少对于问题中提出的用例而言)。

让我们确保调用者满意:

  const carList: Array<Car> = [ id: 111, horsePower: 1337, date: new Date() ];
  this.ifAlreadyExists(carList, 'horsePower', 1337, 'id', 100);

  const bookList: Array<Book> = [ id: '222', title: 'Title 1', date: new Date() ];
  this.ifAlreadyExists(bookList, 'title', 'Title 1', 'id', '200');

那些编译得很好,所以看起来不错。


所以调用签名很好。现在我们需要实现:

ifAlreadyExists(
  data: Array<Record<string, string | number | undefined>>,
  key: string, value: string | number, idKey: string, idValue: string | number
) 

  if (typeof value === 'string' && typeof key === 'string') 
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          (item[key] as string).trim().toLowerCase() ===
          value.trim().toLowerCase())
    ).length === 0;
   else 
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key] === value
      ).length === 0
    );
  

编译器对重载实现的检查比调用签名更松散,主要是因为它无法真正负担得起正确执行所需的分析,而且它在松散方面犯了错误;详情请见this answer。

所以对于data,我们可以将类型一直扩大到Array&lt;Record&lt;string, string | number | undefined&gt;,编译器会很高兴地让我们用我们想要的任何键索引data的每个元素,并读取或写入@的任何值987654369@、numberundefined 类型,或它们的任何联合。这使得大部分实现代码都可以顺利编译,除了:

item[key].trim().toLowerCase() // error!

就像您在原始问题中所说的那样。那是因为编译器仍然认为item[key] 可能是number,即使typeof value === 'string'。是的,我们通过拥有这两个调用签名来防止这种情况发生,但是实现不知道调用签名。它只知道data 持有string | number | undefined 属性的bag 元素,不知道key 键处的属性类型将与value 相同。而且真的没有什么好方法可以让编译器相信这一点;检查typeof item[key] 比重写代码以便编译器提前知道 更容易。也就是说:value是联合类型,item[key]是联合类型,相互关联时编译器将它们视为独立的,而相互关联的联合类型并不是编译器真正支持的;有关相关问题,请参阅 microsoft/TypeScript#30581。

到目前为止,这里最简单的事情就是我通常在面对关联工会时所做的事情;使用type assertion 来告诉编译器item[key] 的类型,然后继续:

(item[key] as string).trim().toLowerCase() // okay

类型断言和重载实现都允许您编写编译器无法验证为类型安全的代码。因此,没有编译器警告的事实是一种方便,而不是类型安全的保证。当您编写重载实现或类型断言时,您正在从编译器中承担一些检查类型安全的责任。在这种情况下确实没有其他好的选择,因为编译器无法处理这个责任。但这意味着您应该特别小心并仔细检查您所做的事情是否安全。显然你可以写

(item[key] as number).toFixed(2).toLowerCase() // also okay

并且编译器会很乐意允许它,即使您在运行时会遇到问题。所以要小心!

Playground link to code

【讨论】:

以上是关于字符串或数字类型的打字稿通用键的主要内容,如果未能解决你的问题,请参考以下文章

如何确定打字稿、数字数组或字符串数​​组中的变量类型?

具有未知键的对象的打字稿类型,但只有数值?

如何在打字稿中使用可能的字符串和数字索引确定对象的类型?

打字稿:允许泛型类型仅是具有“字符串”属性的对象

打字稿通用承诺返回类型

打字稿:如何制作接受对象的类型,其键匹配通用但所有值都是值参数的映射函数