确保 TypeScript 联合类型在整个代码中保持狭窄。

huangapple go评论68阅读模式
英文:

Ensuring TypeScript unions remain narrow throughout the code

问题

I can help you with the translation. Here's the translated content:

我有一个流程,在这个流程中,我希望创建一个包含所需信息的对象,而不是添加一堆if语句,这样在执行时阅读起来更线性。我在下面创建了一个简单且刻意的示例。

```typescript
type ItemOne = {
  type: 'itemOne'
  fn: typeof funcOne
  args: Parameters<typeof funcOne>[0]
}

type ItemTwo = {
  type: 'itemTwo',
  fn: typeof funcTwo
  args: Parameters<typeof funcTwo>[0]
}

type Item = ItemOne | ItemTwo

function funcOne({ name }: { name: string }) {
  console.log(name)
}

function funcTwo({ age }: { age: number }) {
  console.log(age)
}

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }

  return { type: 'itemTwo', fn: funcTwo, args: { age: 22  } } as const
}

const item = createItem(false) 

item.fn(item.args)

当我调用item.fn(item.args)函数时,TypeScript报错类型为' { name: string; } | { age: number; }'的参数不能分配给类型' { name: string; } & { age: number; }'。 有没有办法确保TypeScript可以保持item.args的缩小版本?

还有一个链接,可以在TypeScript Playground中查看示例。


如果需要更多帮助,请随时告诉我。

<details>
<summary>英文:</summary>

I have a flow, in which, rather than adding a number of if statements, I&#39;d like to create an object that holds the required information so when it comes to execution it reads in a more linear fashion. I&#39;ve created a simple and contrived example of this below.  


```typescript
type ItemOne = {
  type: &#39;itemOne&#39;
  fn: typeof funcOne
  args: Parameters&lt;typeof funcOne&gt;[0]
}

type ItemTwo = {
  type: &#39;itemTwo&#39;,
  fn: typeof funcTwo
  args: Parameters&lt;typeof funcTwo&gt;[0]
}

type Item = ItemOne | ItemTwo


function funcOne({ name }: { name: string }) {
  console.log(name)
}

function funcTwo({ age }: { age: number }) {
  console.log(age)
}

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: &#39;itemOne&#39;, fn: funcOne, args: { name: &#39;Dave&#39; } } as const
  }

  return { type: &#39;itemTwo&#39;, fn: funcTwo, args: { age: 22  } } as const
}


const item = createItem(false) 

item.fn(item.args)

When I get around to calling the function item.fn(item.args) TypeScript complains Argument of type &#39;{ name: string; } | { age: number; }&#39; is not assignable to parameter of type &#39;{ name: string; } &amp; { age: number; }&#39;. Is there a way I can ensure that TypeScript can keep a narrowed version of item.args?

There's also a link the example within the TypeScript playground

答案1

得分: 2

这看起来需要使用重载,其中您希望返回类型根据给定参数的类型而变化:

function createItem(shouldCreateItemOne: true): ItemOne;
function createItem(shouldCreateItemOne: false): ItemTwo;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }

  return { type: 'itemTwo', fn: funcTwo, args: { age: 22  } } as const
}

如果您不希望支持一般的boolean类型的函数调用,最后一个重载是可选的。使用重载允许TypeScript 推断此调用的正确类型:

const item = createItem(false)
//    ^? ItemTwo
item.fn(item.args) // okay

Playground

如果您有多于两个项目并需要更多的灵活性,那么这可能不适合您。查看@jcalz的评论以获得使用通用索引访问的替代方法。

您还可以将项目类型重构为映射:

interface ItemMap {
  itemOne: typeof funcOne;
  itemTwo: typeof funcTwo;
}

type Item<K extends keyof ItemMap = keyof ItemMap> = {
  [P in K]: { type: P; fn: ItemMap[P]; args: Parameters<ItemMap[P]>[0] };
}[K];

现在,createItem 声明看起来像这样:

function createItem(shouldCreateItemOne: true): Item<"itemOne">;
function createItem(shouldCreateItemOne: false): Item<"itemTwo">;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {

Playground

英文:

This looks like a job for overloads, where you want the return type to change based on the types of the parameters given:

function createItem(shouldCreateItemOne: true): ItemOne;
function createItem(shouldCreateItemOne: false): ItemTwo;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: &#39;itemOne&#39;, fn: funcOne, args: { name: &#39;Dave&#39; } } as const
  }

  return { type: &#39;itemTwo&#39;, fn: funcTwo, args: { age: 22  } } as const
}

The last overload is optional if you do not wish to support calls to the function with the general boolean type. Using an overload allows TypeScript to deduce the right type for this call:

const item = createItem(false)
//    ^? ItemTwo
item.fn(item.args) // okay

Playground


If you have more than two items and need more flexibility, then this may not be the best for you. See @jcalz's comment for an alternative using generic indexed access.


You can also refactor your Item types into a map:

interface ItemMap {
  itemOne: typeof funcOne;
  itemTwo: typeof funcTwo;
}

type Item&lt;K extends keyof ItemMap = keyof ItemMap&gt; = {
  [P in K]: { type: P; fn: ItemMap[P]; args: Parameters&lt;ItemMap[P]&gt;[0] };
}[K];

with the createItem declaration now looking like

function createItem(shouldCreateItemOne: true): Item&lt;&quot;itemOne&quot;&gt;;
function createItem(shouldCreateItemOne: false): Item&lt;&quot;itemTwo&quot;&gt;;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {

Playground

答案2

得分: 1

TypeScript不知道如何处理“相关联合类型”,如microsoft/TypeScript#30581中所描述。如果您有一个类型为Item的值item,应该能够调用item.fn(item.args)。但编译器无法看到这一点,因为类型系统无法在抽象中表示item.fnitem.args之间的关联。如果它能将item缩小为ItemOneItemTwo中的一个,那就没问题:

declare const item: Item;
if (item.type === "itemOne") {
  item.fn(item.args);
} else {
  item.fn(item.args);
}

但对于只有Item的情况,item.args的类型是联合类型{name: string} | {age: number},而item.fn的类型是联合类型((a: {name: string}) => void) | ((a: {age: number}) => void),通常情况下,使用参数的联合类型调用函数的联合类型是不安全的... 如果有两个类型为Item的值itemAitemB,您不应该调用itemA.fn(itemB.args),对吗?遗憾的是,编译器无法区分这一点和您尝试做的事情。

推荐处理相关联合类型的方法在microsoft/TypeScript#47109中有描述。它涉及从联合类型进行重构,转向泛型,在泛型中表达操作为通用的索引访问映射类型

首先,您应该创建一个简单的“映射”对象类型,用它来构建更复杂的Item类型:

interface ItemMap {
  itemOne: { name: string };
  itemTwo: { age: number }
}

现在,我们可以将Item定义为通用的分布式对象类型(正如在ms/TS#47109中定义的)如下:

type Item<K extends keyof ItemMap = keyof ItemMap> =
  { [P in K]: {
    type: P,
    fn: (arg: ItemMap[P]) => void;
    args: ItemMap[P]
  } }[K];

如果需要,您可以恢复ItemOneItemTwo类型:

type ItemOne = Item<"itemOne">
type ItemTwo = Item<"itemTwo">

Item类型本身等同于ItemOne | ItemTwo,这是由于定义中的默认泛型类型参数

从某种意义上说,我们没有改变类型的任何内容,但作为映射类型的索引表示有助于编译器在编写通用操作时跟踪事物:

function processItem<K extends keyof ItemMap>(item: Item<K>) {
  const fn = item.fn; 
  const arg = item.args;
  fn(arg);
}

或者只需:

function processItem<K extends keyof ItemMap>(item: Item<K>) {
  item.fn(item.args);
}

您可以看到,item.args的类型是通用类型ItemMap[K],而item.fn的类型是通用类型(arg: ItemMap[K]) => void,这意味着编译器确实看到item.argsitem.fn函数的有效参数。

让我们进行测试:

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
  }
  return { type: 'itemTwo', fn: funcTwo, args: { age: 22 } } as const
}

const item = createItem(false);
processItem(item);

这仍然有效,因为编译器看到返回值是有效的Item。当我们调用它时,我们得到一个Item(它是Item<keyof ItemMap>,也就是ItemOne | ItemTwo)。

希望这对您有所帮助。

英文:

TypeScript doesn't know how to deal with "correlated union types" as described in microsoft/TypeScript#30581. It is the case that if you have a value item of type Item, you should be able to call item.fn(item.args). But the compiler cannot see that because the type system is unable to represent the correlation between item.fn and item.args in the abstract. If it can narrow item to either ItemOne or ItemTwo it would be fine:

declare const item: Item;
if (item.type === &quot;itemOne&quot;) {
  item.fn(item.args);
} else {
  item.fn(item.args);
}

but with just Item, the type of item.args is the union {name: string} | {age: number} while the type of item.fn is the union ((a: {name: string}) =&gt; void) | ((a: {age: number}) =&gt; void), and generally speaking it is not safe to call a union of functions with a union of parameters... wwhat if there were itemA and itemB both of type Item. You shouldn't call itemA.fn(itemB.args), right? Well the compiler can't tell the difference between that and what you're trying to do, unfortunately.


The recommended approach to dealing with correlated unions is described at microsoft/TypeScript#47109. It involves a refactoring away from unions, and to generics, where the operations are expressed as generic indexed accesses into mapped types.

First you should make a simple "mapping" object type from which you build the more complicated Item types:

interface ItemMap {
  itemOne: { name: string };
  itemTwo: { age: number }
}

Now we can make Item a generic distributive object type (as coined in ms/TS#47109) as follows:

type Item&lt;K extends keyof ItemMap = keyof ItemMap&gt; =
  { [P in K]: {
    type: P,
    fn: (arg: ItemMap[P]) =&gt; void;
    args: ItemMap[P]
  } }[K];

You can recover your ItemOne and ItemTwo types if you want:

type ItemOne = Item&lt;&quot;itemOne&quot;&gt;
type ItemTwo = Item&lt;&quot;itemTwo&quot;&gt;;

and the type Item by itself is equivalent to ItemOne | ItemTwo due to the default generic type argument in the definition.

In some sense we haven't changed anything about your types, but the representation as indexes into a mapped type helps the compiler follow things when we write a generic operation:

function processItem&lt;K extends keyof ItemMap&gt;(item: Item&lt;K&gt;) {
  const fn = item.fn; 
  // const fn: (arg: ItemMap[K]) =&gt; void
  const arg = item.args;
  // const arg: ItemMap[K]
  fn(arg); // okay
}

or just

function processItem&lt;K extends keyof ItemMap&gt;(item: Item&lt;K&gt;) {
  item.fn(item.args); // okay
}

You can see that item.args is of generic type ItemMap[K], while item.fn is of generic type (arg: ItemMap[K]) =&gt; void, meaning that the compiler definitely sees that item.args is a valid argument to the item.fn function.


And let's test it out:

function createItem(shouldCreateItemOne: boolean): Item {
  if (shouldCreateItemOne) {
    return { type: &#39;itemOne&#39;, fn: funcOne, args: { name: &#39;Dave&#39; } } as const
  }
  return { type: &#39;itemTwo&#39;, fn: funcTwo, args: { age: 22 } } as const
}

This still works because the compiler sees that the return value is a valid Item. And when we call it, we get an Item (which is Item&lt;keyof ItemMap&gt; which is ItemOne | ItemTwo):

const item = createItem(false);

and finally, we can process the item with our processing function:

processItem(item); // okay
// function processItem&lt;keyof ItemMap&gt;(item: Item&lt;keyof ItemMap&gt;): void

Note that the type argument K is inferred as keyof ItemMap because item is an Item&lt;keyof ItemMap&gt;. This would still work even if item were just an ItemOne or an ItemTwo.

Playground link to code

huangapple
  • 本文由 发表于 2023年3月21日 02:44:34
  • 转载请务必保留本文链接:https://go.coder-hub.com/75794155-2.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定