英文:
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'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've created a simple and contrived example of this below.
```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)
When I get around to calling the function item.fn(item.args)
TypeScript complains Argument of type '{ name: string; } | { age: number; }' is not assignable to parameter of type '{ name: string; } & { age: number; }'.
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
如果您有多于两个项目并需要更多的灵活性,那么这可能不适合您。查看@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 {
英文:
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: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
}
return { type: 'itemTwo', 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
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<K extends keyof ItemMap = keyof ItemMap> = {
[P in K]: { type: P; fn: ItemMap[P]; args: Parameters<ItemMap[P]>[0] };
}[K];
with the createItem
declaration now looking like
function createItem(shouldCreateItemOne: true): Item<"itemOne">;
function createItem(shouldCreateItemOne: false): Item<"itemTwo">;
function createItem(shouldCreateItemOne: boolean): Item;
function createItem(shouldCreateItemOne: boolean): Item {
答案2
得分: 1
TypeScript不知道如何处理“相关联合类型”,如microsoft/TypeScript#30581中所描述。如果您有一个类型为Item
的值item
,应该能够调用item.fn(item.args)
。但编译器无法看到这一点,因为类型系统无法在抽象中表示item.fn
和item.args
之间的关联。如果它能将item
缩小为ItemOne
或ItemTwo
中的一个,那就没问题:
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
的值itemA
和itemB
,您不应该调用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];
如果需要,您可以恢复ItemOne
和ItemTwo
类型:
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.args
是item.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 === "itemOne") {
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}) => void) | ((a: {age: number}) => 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<K extends keyof ItemMap = keyof ItemMap> =
{ [P in K]: {
type: P,
fn: (arg: ItemMap[P]) => void;
args: ItemMap[P]
} }[K];
You can recover your ItemOne
and ItemTwo
types if you want:
type ItemOne = Item<"itemOne">
type ItemTwo = Item<"itemTwo">;
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<K extends keyof ItemMap>(item: Item<K>) {
const fn = item.fn;
// const fn: (arg: ItemMap[K]) => void
const arg = item.args;
// const arg: ItemMap[K]
fn(arg); // okay
}
or just
function processItem<K extends keyof ItemMap>(item: Item<K>) {
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]) => 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: 'itemOne', fn: funcOne, args: { name: 'Dave' } } as const
}
return { type: 'itemTwo', 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<keyof ItemMap>
which is ItemOne | ItemTwo
):
const item = createItem(false);
and finally, we can process the item with our processing function:
processItem(item); // okay
// function processItem<keyof ItemMap>(item: Item<keyof ItemMap>): void
Note that the type argument K
is inferred as keyof ItemMap
because item
is an Item<keyof ItemMap>
. This would still work even if item
were just an ItemOne
or an ItemTwo
.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论