英文:
Mapping value to compatible function creates uncallable function union
问题
以下是要翻译的代码部分:
// Module A
interface A extends Identifiable<ModuleID.A> {
item: 'value:A';
}
export const moduleA: Module<A> = {
change(a: A) {}
}
// Module B
interface B extends Identifiable<ModuleID.B> {
item: 'value:B';
}
export const moduleB: Module<B> = {
change(a: B) {}
}
// Map id to module containing the correct `change` function
const moduleMap = {
[ModuleID.A]: moduleA,
[ModuleID.B]: moduleB
}
// While conceptually correct, this does not work with typescript
function changeValue(value: A | B) {
const module = moduleMap[value.id]
// The intersection 'A & B' was reduced to 'never' because
// property 'item' has conflicting types in some constituents.
module.change(value)
}
请注意,我已经将代码部分翻译成中文。如果您需要进一步的帮助,请随时提问。
英文:
I have to deal with ingesting one of many possible values, but within those values there is an id which can be used to get the correct change
function. With typescript, however this does not work because the union of all functions is not callable.
type Module<T> = {
change: (value: T) => void
}
enum ModuleID {
A = "id:A",
B = "id:B"
}
interface Identifiable<ID extends ModuleID> {
id: ID
}
// Module A
interface A extends Identifiable<ModuleID.A> {
item: 'value:A'
}
export const moduleA: Module<A> = {
change(a: A) {}
}
// Module B
interface B extends Identifiable<ModuleID.B> {
item: 'value:B'
}
export const moduleB: Module<B> = {
change(a: B) {}
}
// Map id to module containing the correct `change` function
const moduleMap = {
[ModuleID.A]: moduleA,
[ModuleID.B]: moduleB
}
// While conceptually correct, this does not work with typescript
function changeValue(value: A | B) {
const module = moduleMap[value.id]
// The intersection 'A & B' was reduced to 'never' because
// property 'item' has conflicting types in some constituents.
module.change(value)
}
How could I restructure this so that I can call the appropriate change
method based on the value's id
in a type-safe way.
答案1
得分: 1
这是我称之为相关联联合类型的问题,如microsoft/TypeScript#30581所述。在changeValue()
的主体内,module.change
的类型和value
的类型都是联合类型,但编译器无法建模它们之间的相关性。如果编译器能够首先将value
缩小到A
,然后缩小到B
,那将是理想的:
function changeValue(value: A | B) {
if (value.id === ModuleID.A) {
const module = moduleMap[value.id]
module.change(value) // okay
} else {
const module = moduleMap[value.id]
module.change(value) // okay
}
}
但它只能通过不同的控制流分析来实现,这不是你想要的,因为它是冗余的,并且随着输入联合的增加而冗余增加。它无法一次查看一行代码并进行每次缩小的评估。
建议处理相关联合的解决方案在microsoft/TypeScript#47109中有描述,涉及到重构类型,以便使用泛型而不是联合,并且泛型操作明确地编写为映射类型和索引类型的术语。目标是changeValue()
应该是以某种K
类型的关键类型为泛型,以便value
被视为类型F<K>
的一部分,而module.change
被视为类型(value: F<K>) => void
的一部分。这将允许调用。
这是一种实现方法。首先,让我们创建一个类型,可以根据ModuleID
查找change()
的参数类型:
type Modules = A | B;
type ChangeArg = { [M in Modules['id']]: M }
/* type ChangeArg = {
"id:A": A;
"id:B": B;
} */
然后,我们将明确写出moduleMap
的类型,将其作为此类型的映射类型:
const moduleMap: { [K in ModuleID]: Module<ChangeArg[K]> } = {
[ModuleID.A]: moduleA,
[ModuleID.B]: moduleB
}
最后,我们将使changeValue
在K
,涉及的ModuleID
的类型上具有泛型:
function changeValue<K extends ModuleID>(value: Identifiable<K> & ChangeArg[K]) {
const _value: Identifiable<K> = value;
const module = moduleMap[_value.id]
module.change(value)
}
这需要一些解释;为了使它工作,编译器需要理解value
是Identifiable<K>
的类型,以便value.id
是类型K
,还需要理解value
是类型ChangeArg[K]
,以便将其视为有效的输入到moduleMap[value.id]
。这有点复杂,所以我通过定义value
的类型为这两种类型的交集来帮助编译器(尽管ChangeArg[K]
已经是Identifiable<K>
的子类型),然后在获取其id
属性之前将其扩展为Identifiable<K>
(这样编译器将其保持为通用的K
,不会变得不太可用)。
您可以验证module.change
现在被视为类型(value: ChangeArg[K]) => void
,接受类型Identifiable<K> & ChangeArg[K]
的参数,因此changeValue()
的主体编译不会出错。
此外,让我们确保调用者仍然有合理的行为:
changeValue({ id: ModuleID.A, item: 'value:A' }); // okay
changeValue({ id: ModuleID.A, item: 'value:B' }); // error
changeValue({ id: ModuleID.B, item: 'value:B' }); // okay
changeValue({ id: ModuleID.B, item: 'value:A' }); // error
看起来不错!
最后,可能不值得您的时间/精力/复杂性来按照这种方式进行重构。如果您只想要编译器验证的类型安全性并且不在乎复杂性,那么重构可能是一个不错的选择;如果您想要编译器验证的类型安全性但不想要复杂性,您可能希望通过冗余控制流来进行缩小。如果您更关心推进而不太关心编译器验证的类型安全性,那么断言可能是解决方案。这取决于您。
英文:
This is an issue I call correlated unions, as described in microsoft/TypeScript#30581. Inside the body of changeValue()
, the type of module.change
and the type of value
are both union types, but the compiler is unable to model the correlation between them. If the compiler could narrow value
first to A
and then to B
, it would be happy:
function changeValue(value: A | B) {
if (value.id === ModuleID.A) {
const module = moduleMap[value.id]
module.change(value) // okay
} else {
const module = moduleMap[value.id]
module.change(value) // okay
}
}
But it can only do so via different control flow, which is not what you want to do, since it's redundant and scales in redundancy with the input union. It can't look at a single line of code and evaluate it for each narrowing at once.
The recommended solution for dealing with correlated unions is described in microsoft/TypeScript#47109, and involves refactoring the types so that you use generics instead of unions, and that the generic operations are written explicitly in terms of mapped types and indexes into such types. The goal is that changeValue()
should be generic in some keylike type K
, such that value
is seen of type F<K>
for some F
, and module.change
is seen of type (value: F<K>) => void
for the same F
. This will allow the call.
Here's one way to do it. First let's make a type in which we can look up the argument type for change()
based on the ModuleID
:
type Modules = A | B;
type ChangeArg = { [M in Modules as M['id']]: M }
/* type ChangeArg = {
"id:A": A;
"id:B": B;
} */
Then we will write the type of moduleMap
explicitly as a mapped type on this:
const moduleMap: { [K in ModuleID]: Module<ChangeArg[K]> } = {
[ModuleID.A]: moduleA,
[ModuleID.B]: moduleB
}
And finally, we will make changeValue
generic in K
, the type of the ModuleID
involved:
function changeValue<K extends ModuleID>(value: Identifiable<K> & ChangeArg[K]) {
const _value: Identifiable<K> = value;
const module = moduleMap[_value.id]
module.change(value)
}
That needs some explanation; in order for this to work, the compiler needs to understand that value
is an Identifable<K>
so that value.id
is of type K
, and also that value
is of type ChangeArg[K]
so that its seen as a valid input to moduleMap[value.id]
. This turns out to be a little tricky, so I help the compiler along by defining value
's type as the intersection of those two types (even though ChangeArg[K]
is already a subtype of Identifiable<K>
for every K
), and then widening to Identifiable<K>
before getting its id
property (so the compiler keeps it generic as K
and doesn't widen to something less usable).
You can verify that module.change
is now seen as type (value: ChangeArg[K]) => void
, which accepts an argument of type Identifiable<K> & ChangeArg[K]
, and so the body of changeValue()
compiles without error.
Let's also make sure that callers still have reasonable behavior:
changeValue({ id: ModuleID.A, item: 'value:A' }); // okay
changeValue({ id: ModuleID.A, item: 'value:B' }); // error
changeValue({ id: ModuleID.B, item: 'value:B' }); // okay
changeValue({ id: ModuleID.B, item: 'value:A' }); // error
Looks good!
Finally, it might not be worth the time/effort/complexity to you to refactor this way. It's perfectly reasonable for you to just leave your code as-is and just use a type assertion to suppress the warning, as long as you're convinced that it's correct:
const module = moduleMap[value.id] // Module<A> | Module<B>
module.change(value as never); // I know this line is fine so leave me alone
Which way you proceed depends on what's more important for your use case: if you want compiler-verified type safety and don't care about complexity then refactoring might be the way to go; if you want compiler-verified type safety but without the complexity, you might want to do the narrowing via redundant control flow. And if you care less about compiler-verified type safety that making forward progress, then an assertion might be your solution. It's up to you.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论