英文:
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<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 } };
};
While this should produce the correct result, typescript raises an error:
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"'
I can resolve the error by discriminating the type before creating the new object as so:
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 } };
}
};
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">
等同于您的Test1
,Test<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<true>
and Test<false>
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<T>
and Test2<T>
, the compiler loses the thread for Test<T>
. You can write different code blocks for different cases, as you've shown with if (node.name === "test1")
, 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<
T extends boolean,
K extends keyof TestMap = keyof TestMap
> = { [P in K]: {
name: P,
attrs: TestMap[P] & { 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<T, K>
takes the place of your Test<T>
. It's a distributive object type, so Test<T, keyof TestMap>
turns out to be equivalent to your Test<T>
, and Test<T, "test1">
is equivalent to your Test1
, and Test<T, "test2"> to
Test2`. (So if you need them, you can still define them
type Test1<T extends boolean> = Test<T, "test1">
type Test2<T extends boolean> = Test<T, "test2">
.) Anyway now your addId()
function can be generic in K extends keyof TestMap
, and it will just work:
const addId = <K extends keyof TestMap>(
node: Test<false, K>, id: string): Test<true, K> => {
return { name: node.name, attrs: { ...node.attrs, id } };
};
And when you call it, the compiler will distinguish between "test1"
and "test2"
cases properly, if you want:
declare const t1: Test1<false>;
const r1: Test1<true> = addId(t1, "x")
declare const t2: Test2<false>;
const r2: Test2<true> = addId(t2, "x")
But it can also accept the full union:
declare const t: Test<false>;
const r: Test<true> = addId(t, "z");
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论