TypeScript forEach 如何推断联合的类型并更改返回类型

B R*_*Rob 6 typescript typescript-generics

我们想要获取一个文档类型并改变它的一些字段,然后返回具有新类型的新文档。我们想根据一组值(collectionDataFields在下面的示例中)来确定要改变的字段。

type AllDocuments = {
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
} | {
    __typename: 'dog'
    a: '123',
    c: '123',
    z: 'xyz'
}

const collectionDateFields = {
    cat: ['a', 'b'],
    dog: ['a', 'c']
} as const

export const useStringsToNums = (document: AllDocuments) => {
  const dateFields = collectionDateFields[document.__typename]

  dateFields.forEach((field) => {
    document[field] = parseInt(document[field])
  })
  return document
}

Run Code Online (Sandbox Code Playgroud)

在示例中,dateFields输入为,readonly ["a", "b"] | readonly ["a", "c"]但个人输入fieldany

document返回的AllDocuments,虽然我们希望它有改变的类型的字段(如一个连number)。

TS 游乐场示例

编辑:对于用法的一个例子,我会做这样的事情:

useStringsToNums({
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
})
Run Code Online (Sandbox Code Playgroud)

在这种情况下,该函数将匹配的属性__typename: 'cat'转换为数字(带有parseInt)。

jca*_*alz 3

我们想要采用文档类型并改变它的一些字段

如果您有一个类型的对象,其中string某个键上有一个 -valued 属性,则您无法将 a 分配number给该相同的属性,而编译器不会抱怨。TypeScript 背后的动机之一是为 JavaScript 添加一个强大的静态类型系统,而将 a 分配number给应该是 a 的东西则string违背了这一点:

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}
Run Code Online (Sandbox Code Playgroud)

可以使用类型断言来抑制这些错误:

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}
Run Code Online (Sandbox Code Playgroud)

但从长远来看,这一切只会让事情变得更糟。TypeScript 不会模拟任意类型突变,只会缩小范围。无法说在调用 后doSomething(doc), 的类型doc已更改,因此以前的string属性现在是number。通过断言 anumber是 a string,您对编译器撒了谎,并且很容易在运行时遇到错误:

const doc: AllDocuments = { __typename: "cat", a: "123", b: "123" };
doSomething(doc);
try {
  doc.a.toUpperCase(); // compiles but has runtime error:
} catch (e) {
  console.log(e); // doc.a.toUpperCase is not a function    
}
Run Code Online (Sandbox Code Playgroud)

假设您不想将这些属性类型扩展到"123"可以同时处理"123"和 的123类型,那么最好创建一个您正在寻找的类型的新对象,并将原始对象视为不可变的:

function doSomethingElse(doc: AllDocuments) {
  return { ...doc, a: 123 }
}
const newDoc = doSomethingElse(doc);
/* const newDoc: {
  a: number;
  __typename: 'cat';
  b: '123';
 } | {
  a: number;
  __typename: 'dog';
  c: '123';
 } */
console.log(newDoc.a.toFixed(2)); // "123.00"
Run Code Online (Sandbox Code Playgroud)

对于这个答案的其余部分,我将使用这种方法。



现在回答你剩下的问题。您遇到了我所说的相关联合类型的问题,如microsoft/TypeScript#30581中所述。

在下面的代码中:

  const dateFields = collectionDateFields[document.__typename]
  dateFields.forEach((field: "a" | "b" | "c") => {
    document[field] // oops
  })
Run Code Online (Sandbox Code Playgroud)

即使我们用field我们期望的类型进行注释,编译器也无法看到它document有一个名为 的键field。的类型document是两个不同类型的并集,并且 的类型dateField也是两个不同类型的并集,并且 的类型field也是并集。但编译器将这三个联合视为独立不相关的。据其了解,document.__typename可能是"cat",但也field可能是"c"知道这是不可能的,但是编译器丢失了线程。


有时可以重构代码以避免相关联合问题。不幸的是,尽管花了一些时间尝试从编译器中获取某种类型的安全类型,但我能得到的最接近的类型还不够有用,不值得这么复杂:

const useDatesToLuxon = <T extends AllDocuments['__typename']>(
  document: Extract<AllDocuments, { __typename: T }>
) => {
  type Fields = (typeof collectionDateFields)[T][number];
  const dateFields: readonly Fields[] = collectionDateFields[document.__typename]
  const doc = document as Record<Fields, string>;
  const newDoc = {} as Record<Fields, number>;
  dateFields.forEach((field) => {
    newDoc[field] = parseInt(doc[field])
  })
  const ret = { ...(document as Omit<typeof document, Fields>), ...newDoc };
  type Ret = typeof ret;
  return ret as { [K in keyof Ret]: Ret[K] }
}
Run Code Online (Sandbox Code Playgroud)

这是很多类型杂耍,最终它只适用于已知为联合的单个成员的参数AllDocuments

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected
Run Code Online (Sandbox Code Playgroud)

如果您尝试在其类型仅已知为完整AllDocuments联合的对象上调用它,您会得到错误的输出类型:

function butWait(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* const u: {
    __typename: "cat" | "dog";
    a: number;
    b: number;
    c: number;
    } */
  // UGH, no, that's not the type we want; we cannot programmatically *distribute* the 
  // behavior of useDatesToLuxon across the union members of AllDocuments
}
Run Code Online (Sandbox Code Playgroud)

所以让我们放弃这样的重构。


对于关联联合,没有一个很好的解决方案。解决该问题的一种方法是使用冗余代码,将代码块分成几个相同的块,编译器可以在其中使用控制流分析来遍历不同的情况:

export const useDatesToLuxon = (document: AllDocuments) => {
  switch (document.__typename) {
    case "cat": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
    case "dog": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

这工作得很好,而且类型非常安全。在这种"cat"情况下"dog",编译器确切地知道发生了什么。但这种冗余无法扩展。如果您可以要求编译器假装您编写了此类冗余代码,就像我在microsoft/TypeScript#25051中所要求的那样,那就太好了,但您不能。


对于这种情况,我的一般建议是使用类型断言来放松函数实现内部的类型检查。这将维护类型安全的负担从编译器(这里无法正确完成)转移到了您身上,因此您需要小心。

这是一种可能的方法。首先,让我们计算我们期望从函数中得出的类型:

type CollectionDateFields = typeof collectionDateFields;

type ConvertedDocuments = { 
  [D in AllDocuments as D['__typename']]: { 
    [K in keyof D]: K extends 
      CollectionDateFields[D['__typename']][number] ? number : D[K] 
} }[keyof CollectionDateFields]
Run Code Online (Sandbox Code Playgroud)

这是使用键重新映射来迭代 的每个成员并将其中AllDocuments提到的相关属性转换为。您可以验证它是否正确:collectionDateFieldsnumber

/* type ConvertedDocuments = {
__typename: "cat";
a: number;
b: number;
} | {
__typename: "dog";
a: number;
c: number;
} */
Run Code Online (Sandbox Code Playgroud)

现在我们可以给出useDatesToLuxon一个重载调用签名来描述它的作用:

function useDatesToLuxon<D extends AllDocuments>(
  document: D
): Extract<ConvertedDocuments, Pick<D, '__typename'>>
Run Code Online (Sandbox Code Playgroud)

这是一个通用函数,可以将整个AllDocuments联合转换为整个ConvertedDocuments联合,以及将任何子类型转换为类似的输出类型。现在来实施:

function useDatesToLuxon(_document: AllDocuments): ConvertedDocuments {
  // let's just pretend it's one of the members of the union
  const document = _document as Extract<AllDocuments, { __typename: "cat" }>
  const dateFields = collectionDateFields[document.__typename]
  const temp = {} as Record<typeof dateFields[number], number>
  dateFields.forEach((field) => {
    temp[field] = parseInt(document[field])
  })
  return { ...document, ...temp }
}
Run Code Online (Sandbox Code Playgroud)

在这里,我最容易做出的断言就是假装是document工会成员之一。这在技术上并不正确,但它抑制了编译器的抱怨。我们只需要小心我们是否正确实施了它。

现在来测试一下:

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected

function andThen(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* {
    __typename: "cat";
    a: number;
    b: number;
  } | {
    __typename: "dog";
    a: number;
    c: number;
  } */

}
Run Code Online (Sandbox Code Playgroud)

看起来不错!

Playground 代码链接