在Typescript中向联合类型中的嵌套对象添加属性。

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

Adding property to nested object in union type in Typescript

问题

我有一组对象,每个对象根据其名称字段的值具有不同类型的属性对象字段。我想创建一个函数,可以接受这些对象中的任何一个,并向其属性添加一个id属性。

这是我尝试过的一个最小示例:

type Test1<T extends boolean> = {
  name: "test1";
  attrs: { t1: boolean; id: T extends true ? string : never };
};

type Test2<T extends boolean> = {
  name: "test2";
  attrs: { t2: string; id: T extends true ? string : never };
};

type Test<T extends boolean> = Test1<T> | Test2<T>;

const addId = (node: Test<false>, id: string): Test<true> => {
  return { name: node.name, attrs: { ...node.attrs, id } };
};

虽然这应该产生正确的结果,但TypeScript会引发错误:

Type '{ name: "test1" | "test2"; attrs: { id: string; t1: boolean; } | { id: string; t2: string; }; }' is not assignable to type 'Test<true>'.
  Type '{ name: "test1" | "test2"; attrs: { id: string; t1: boolean; } | { id: string; t2: string; }; }' is not assignable to type 'Test2<true>'.
    Types of property 'name' are incompatible.
      Type '"test1" | "test2"' is not assignable to type '"test2"'.
        Type '"test1"' is not assignable to type '"test2"'.

我可以通过在创建新对象之前对类型进行区分来解决错误,如下所示:

const addId1 = (node: Test<false>, id: string): Test<true> => {
  if (node.name === "test1") {
    return { name: node.name, attrs: { ...node.attrs, id } };
  } else {
    return { name: node.name, attrs: { ...node.attrs, id } };
  }
};

但是,当我们有多于两种类型的节点时,这种方法会变得繁琐。是否有一种简单的方法来解决这个问题,并且可以泛化到更大的联合类型,而不依赖于 any 或类似的东西?


你可以尝试使用类型守卫函数来更优雅地解决这个问题,而不必依赖于 any 或手动进行类型区分。以下是一个示例:

type Test1<T extends boolean> = {
  name: "test1";
  attrs: { t1: boolean; id: T extends true ? string : never };
};

type Test2<T extends boolean> = {
  name: "test2";
  attrs: { t2: string; id: T extends true ? string : never };
};

type Test<T extends boolean> = Test1<T> | Test2<T>;

function isTest1(node: Test<false>): node is Test1<false> {
  return node.name === "test1";
}

function addId(node: Test<false>, id: string): Test<true> {
  if (isTest1(node)) {
    return { ...node, attrs: { ...node.attrs, id } };
  } else {
    return { ...node, attrs: { ...node.attrs, id } };
  }
}

通过使用 isTest1 类型守卫函数,您可以轻松地对节点进行类型区分,而不必为每种情况都手动重复代码。这种方法可以轻松泛化到更大的联合类型,使代码更具可扩展性。

英文:

I have a set of objects that each have different types for an attribute object field depending on their value in a name field. I would like to create a function that can take any of these objects and add an id property to its attributes.

Here is a minimal example of what I have tried:

type Test1&lt;T extends boolean&gt; = {
  name: &quot;test1&quot;;
  attrs: { t1: boolean; id: T extends true ? string : never };
};

type Test2&lt;T extends boolean&gt; = {
  name: &quot;test2&quot;;
  attrs: { t2: string; id: T extends true ? string : never };
};

type Test&lt;T extends boolean&gt; = Test1&lt;T&gt; | Test2&lt;T&gt;;

const addId = (node: Test&lt;false&gt;, id: string): Test&lt;true&gt; =&gt; {
  return { name: node.name, attrs: { ...node.attrs, id } };
};

While this should produce the correct result, typescript raises an error:

Type &#39;{ name: &quot;test1&quot; | &quot;test2&quot;; attrs: { id: string; t1: boolean; } | { id: string; t2: string; }; }&#39; is not assignable to type &#39;Test&lt;true&gt;&#39;.
  Type &#39;{ name: &quot;test1&quot; | &quot;test2&quot;; attrs: { id: string; t1: boolean; } | { id: string; t2: string; }; }&#39; is not assignable to type &#39;Test2&lt;true&gt;&#39;.
    Types of property &#39;name&#39; are incompatible.
      Type &#39;&quot;test1&quot; | &quot;test2&quot;&#39; is not assignable to type &#39;&quot;test2&quot;&#39;.
        Type &#39;&quot;test1&quot;&#39; is not assignable to type &#39;&quot;test2&quot;&#39;

I can resolve the error by discriminating the type before creating the new object as so:

const addId1 = (node: Test&lt;false&gt;, id: string): Test&lt;true&gt; =&gt; {
  if (node.name === &quot;test1&quot;) {
    return { name: node.name, attrs: { ...node.attrs, id } };
  } else {
    return { name: node.name, attrs: { ...node.attrs, id } };
  }
};

but this becomes tedious when we have more than two types of nodes.

Is there any way to resolve this issue easily and in a way that generalizes to larger unions without relying on any or the like?

答案1

得分: 0

TypeScript无法真正处理"相关联的联合类型",如microsoft/TypeScript#30581所述。您的Test<true>Test<false>类型是辨识联合类型,但TypeScript不允许您编写一块代码,将联合类型视为单独考虑每个联合成员。因此,即使{ name: node.name, attrs: { ...node.attrs, id } }Test1<T>Test2<T>中都能工作,编译器也无法正确处理Test<T>

您可以为不同情况编写不同的代码块,就像您在if (node.name === "test1")中所示,但正如您所说,这是多余的。

对于这种情况的推荐解决方法在microsoft/Typescript#47109中有描述。辨识联合类型应该被重构为泛型索引类型映射类型。请阅读GitHub问题以获取更详细的信息。

在您的情况下,重构如下:

interface TestMap {
    test1: { t1: boolean }
    test2: { t2: string }
}

type Test<
    T extends boolean,
    K extends keyof TestMap = keyof TestMap
> = { [P in K]: {
    name: P,
    attrs: TestMap[P] & { id: T extends true ? string : never }
} }[K]

基本的TestMap键值接口表示了name属性与非id属性之间的关系。而Test<T, K>取代了您的Test<T>。它是一个分布对象类型,因此Test<T, keyof TestMap>等同于您的Test<T>Test<T, "test1">等同于您的Test1Test<T, "test2">等同于Test2。(所以如果需要的话,您仍然可以定义它们:

type Test1<T extends boolean> = Test<T, "test1">
type Test2<T extends boolean> = Test<T, "test2">

无论如何,现在您的addId()函数可以接受K extends keyof TestMap的泛型,并且它会正常工作:

const addId = <K extends keyof TestMap>(
    node: Test<false, K>, id: string): Test<true, K> => {
    return { name: node.name, attrs: { ...node.attrs, id } };
};

当您调用它时,如果需要,编译器会正确区分"test1"和"test2"的情况:

declare const t1: Test1<false>;
const r1: Test1<true> = addId(t1, "x")

declare const t2: Test2<false>;
const r2: Test2<true> = addId(t2, "x")

但它也可以接受完整的联合类型:

```typescript
declare const t: Test<false>;
const r: Test<true> = addId(t, "z");

代码播放链接

英文:

TypeScript can't really handle "correlated unions" as described in microsoft/TypeScript#30581. Your Test&lt;true&gt; and Test&lt;false&gt; types are discriminated union types, but TypeScript doesn't allow you to write a single block of code that operates on union types as if each union member were considered separately. So even though { name: node.name, attrs: { ...node.attrs, id } } is seen as working for both Test1&lt;T&gt; and Test2&lt;T&gt;, the compiler loses the thread for Test&lt;T&gt;. You can write different code blocks for different cases, as you've shown with if (node.name === &quot;test1&quot;), but, as you've said, that's redundant.

The recommend workaround for cases like this is described in microsoft/Typescript#47109. Discriminated unions should be refactored to generic indexes into a basic key-value type or mapped types over such a basic key-value type. Read the GitHub issue for more detailed information.

In your case, the refactoring looks like this:

interface TestMap {
    test1: { t1: boolean }
    test2: { t2: string }
}

type Test&lt;
    T extends boolean,
    K extends keyof TestMap = keyof TestMap
&gt; = { [P in K]: {
    name: P,
    attrs: TestMap[P] &amp; { id: T extends true ? string : never }
} }[K]

The basic TestMap key-value interface represents the relationship between the name property and the non-id attrs. And Test&lt;T, K&gt; takes the place of your Test&lt;T&gt;. It's a distributive object type, so Test&lt;T, keyof TestMap&gt; turns out to be equivalent to your Test&lt;T&gt;, and Test&lt;T, &quot;test1&quot;&gt; is equivalent to your Test1, and Test&lt;T, &quot;test2&quot;&gt; to Test2`. (So if you need them, you can still define them

type Test1&lt;T extends boolean&gt; = Test&lt;T, &quot;test1&quot;&gt;
type Test2&lt;T extends boolean&gt; = Test&lt;T, &quot;test2&quot;&gt;

.) Anyway now your addId() function can be generic in K extends keyof TestMap, and it will just work:

const addId = &lt;K extends keyof TestMap&gt;(
    node: Test&lt;false, K&gt;, id: string): Test&lt;true, K&gt; =&gt; {
    return { name: node.name, attrs: { ...node.attrs, id } };
};

And when you call it, the compiler will distinguish between &quot;test1&quot; and &quot;test2&quot; cases properly, if you want:

declare const t1: Test1&lt;false&gt;;
const r1: Test1&lt;true&gt; = addId(t1, &quot;x&quot;)

declare const t2: Test2&lt;false&gt;;
const r2: Test2&lt;true&gt; = addId(t2, &quot;x&quot;)

But it can also accept the full union:

declare const t: Test&lt;false&gt;;
const r: Test&lt;true&gt; = addId(t, &quot;z&quot;);

Playground link to code

huangapple
  • 本文由 发表于 2023年7月3日 13:03:13
  • 转载请务必保留本文链接:https://go.coder-hub.com/76601945.html
匿名

发表评论

匿名网友

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

确定