打字稿:通用类型 "extract keys with value of type X" 未按预期运行

Typescript : Generic type "extract keys with value of type X" does not behave as expected

我定义了以下通用类型,它从类型 T 中提取值为数字的字符串键:

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
    T[K] extends number ?
      K
      : never
    : never
}[keyof T];

我尝试在泛型函数中使用这种类型,如下所示:

function setNumberField<T>(item: T, field: StringKeysMatchingNumber<T>): void {
  item[field] = 1;
}

但是 item[field] = 1; 行错误 Type 'number' is not assignable to type 'T[StringKeysMatchingNumber<T>]'

我尝试了一些不同的方法,例如将函数中的通用类型 T 缩小为明确包含一些字符串键和值 number 的类型,但这没有帮助。

谁能看出是什么问题?这是带有代码示例和更多详细信息的 TS 游乐场:https://www.typescriptlang.org/play?#code/C4TwDgpgBAKhDOwbmgXigbwLACgpQEMAuKAOwFcBbAIwgCcAaXfagfhIpvqbygGMSiOgEtSAcx74AJhyq06uAL65cAelVQAgvCgQAHpD7AIU2AmABpCCB3oARATtQAPlDtS7uUJDOIrNqHQAZWARcX94AFkCYD4AC1ExADk5egAeOERkSAA+FRxvaBCwsQjo2ITxFK46DJzAzGYoAG0LKFEoAGtrAHsAM1gAXQBadig2-WNSKR0hRKhWJvwYVsHdPSmZslS6BaX8cf38DggAN3p9k-OFHEVm7pB+oYBufL7yUiNhHtIoeAhgNV5AAxYQQAA2UjqAAphMZKCQYAwoH0wZCSMVEmUYvFEkD0jAcgBKEinHrCUzYXhwiCUZqoiFSNboACMr1uQA

setNumberField中的

T是黑框。没有人知道 T 是否有带数值的键,即使是你。没有适当的约束。 setNumberField 允许您提供原始值作为第一个参数。这意味着在函数体内,TS 不知道 item[field] 始终是一个数值。但是,TS 在函数调用期间知道它。所以函数有两个级别的类型。一个 - 是函数定义,当 TS 无法猜测 T 类型时,第二个 - 在函数调用期间,当 TS 知道 T 类型并能够推断它时。

最简单的方法是避免变异。您可以 return 新建对象。 考虑这个例子:

type TestType = {
  a: number,
  b?: number,
  c: string,
  d: number
}

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ?
  K
  : never
  : never
}[keyof T];

const setNumberField = <
  Item,
  Field extends StringKeysMatchingNumber<Item>
>(item: Item, field: Field): Item => ({
  ...item,
  [field]: 1
})

declare let foo: TestType

// {
//     a: number;
//     b: string;
// }
const result = setNumberField({ a: 42, b: 'str' }, 'a')

Playground

请记住,TypeScript 不喜欢突变。看我的article


如果你仍然想改变你的参数,你应该重载你的函数。

type TestType = {
  a: number,
  b?: number,
  c: string,
  d: number
}

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ?
  K
  : never
  : never
}[keyof T];

function setNumberField<Item, Field extends StringKeysMatchingNumber<Item>>(item: Item, field: Field): void;
function setNumberField(item: Record<string, number>, field: string): void {
  item[field] = 2
}

declare let foo: TestType

const result1 = setNumberField({ a: 42, b: 'str' }, 'a') // ok
const result2 = setNumberField({ a: 42, b: 'str' }, 'b') // expected error

Playground

函数重载没有那么严格。您可能已经注意到,此函数类型定义 function setNumberField(item: Record<string, number>, field: string) 允许您仅使用所有值为数字的对象。但这种情况并非如此。这就是为什么我用另一层重载了这个函数。底部的一个用于函数体。最上面的 StringKeysMatchingNumber 控制函数参数。


更新

Why adding a constraint such as T extends Record<string, number> is not enough to make TS aware of the type of item[field]

考虑一下:

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: K extends string ?
  T[K] extends number ? // This line does not mean that T[K] is equal to number
  K
  : never
  : never
}[keyof T];

这一行T[K] extends number表示T[K]是数字的子类型。可以是 number & {__tag:'Batman'}。另外,请记住,StringKeysMatchingNumber 可能 return never 并且号码不可分配给 never:

declare let x: never;
x = 1 // error

请注意,使用 {foo: 42} 等静态参数调用 StringKeysMatchingNumber 会产生预期结果 "foo":

type Result = StringKeysMatchingNumber<{ foo: 42 }> // foo

但是在函数体内解析 StringKeysMatchingNumber 是完全不同的历史。将鼠标悬停在 Result 内部函数上 参见示例:

function setNumberField<
  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {

  type Result = StringKeysMatchingNumber<T> // resolving T inside a function

  const value = item[field];

  item[field] = 1; // error
  value.toExponential // ok
}

item[field]解析为T[Field],不是number类型。它是 number 类型的子类型。你仍然可以打电话 toExponential。理解 item[field] 是一种具有 number 类型的所有属性但也可能包含一些其他属性的类型非常重要。 number 从 TS 的角度来看不是原始的。


//"toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type NumberKeys = keyof number

看到这个:


function setNumberField<
  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
  let numSupertype = 5;
  let numSubtype = item[field]
  
  numSupertype = numSubtype // ok
  numSubtype = numSupertype // expected error
}

numSubtype 可分配给 numSupertypeitem[field] 可以赋值给 number 类型的任何变量,而 number 不能赋值给 item[field].

最后一题 您要将数字分配给 item[field] 的方式是否足够安全?

type StringKeysMatchingNumber<T> = {
  [K in keyof T]-?: T[K] extends number ? K : never
}[keyof T];


function setNumberField<

  T extends Record<string, number>,
  Field extends StringKeysMatchingNumber<T>
>(item: T, field: Field) {
  item[field] = 2
}

type BrandNumber = number & { __tag: 'Batman' }

declare let brandNumber: BrandNumber
type WeirdDictionary = Record<string, BrandNumber>

const obj: WeirdDictionary = {
  property: brandNumber
}

setNumberField(obj, 'foo')

setNumberField 需要一个字典,其中每个值都扩展 number 类型。这意味着该值可能是 number & { __tag: 'Batman' }。我知道,从开发人员的角度来看这很奇怪,但从类型的角度来看却不是。它只是 number 的子类型,此技术用于模拟标称类型。 如果您将 2 无误地分配给 item[field] 会发生什么?调用此函数后,您希望每个值都为 BrandNumber 但事实并非如此。

所以,TypeScript 在这里做得很好 :D

您可以在我的 article

中找到有关函数参数推断的更多信息