Typescript 流分析不适用于类型取决于键的函数字典



我正在使用打字稿 4.6

在我的项目中,我有一个名为FooterFormElement的类型,它具有判别属性type

type FooterFormElement = {type:"number",...}|{type:"button",...};

为了实例化这些元素,我有一个名为footerFormElementDictionary的字典:

footerFormElementDictionary:{
[key in FooterFormElement["type"]]: (element: FooterFormElement & { type: key }) => HTMLElement
}

问题是,当我尝试使用此字典时,如以下示例所示

this.footerFormElementDictionary[item.type](item)

我收到错误:Argument of type 'FooterFormElement' is not assignable to parameter of type 'never'似乎打字稿编译器无法"展开"不同值的type流分析。

我可以解决此问题的任何方法(除了丑陋的非类型安全解决方法)。

下面是一个缩短的最小可重现示例:

type FooterFormData = { [k: string]: number };
type FooterFormElement = {
type: "button"; Action: (data: FooterFormData) => void;
} | {
type: "number"; label: string; Action?: (value: number) => void;
}
declare const footerFormElementDictionary: {
[K in FooterFormElement["type"]]: (
element: FooterFormElement & { type: K },
formData: FooterFormData
) => HTMLElement;
}
function MakeFooterForm(elements: FooterFormElement[]): HTMLElement[] {
let formData: FooterFormData = {};
return elements.map((item) =>
footerFormElementDictionary[item.type](item, formData)
);
}

下面是一个可重现的示例,展示了该问题。

为了具体和简洁起见,我将指定您的类型,与外部链接中的类型略有不同:

type FooterFormElement =
{ type: "button"; buttonStuff: string } |
{ type: "number"; numberStuff: string };
declare const footerFormElementDictionary: {
[K in FooterFormElement["type"]]: (
element: Extract<FooterFormElement, { type: K }>
) => HTMLElement;
}

似乎您应该能够抓住任何FooterFormElement类型的item,并调用footerFormElementDictionary[item.type](item)。 但是,如您所见,编译器无法验证这是否是类型安全的:

function problem(item: FooterFormElement) {
const fn = footerFormElementDictionary[item.type];
return fn(item); // error!
// -----> ~~~~
// Argument of type 'FooterFormElement' is not assignable to parameter of type 'never'.
// The intersection '{ type: "number"; numberStuff: string; } & 
// { type: "button"; buttonStuff: string; }'  // was reduced to 'never'
}

让我们使用 IntelliSense 查看编译器看到的fnitem类型:

/* const fn: (
(element: { type: "number"; numberStuff: string }, 
formData: FooterFormData) => HTMLElement
) | (
(element: { type: "button"; buttonStuff: string }, 
formData: FooterFormData) => HTMLElement
) */
/* (parameter) item: 
{ type: "button"; buttonStuff: string; } | 
{ type: "number"; numberStuff: string; } */

fn的类型是函数类型的并集,item的类型也是并集。 这些类型没有,但它们对您的目的不够有用。 编译器将这些联合视为不相关。 编译器只知道fn要么是接受类似"button"输入的东西,要么是键入类似"number"输入的东西,但它不知道是哪个。

因此,它唯一可以安全地接受的输入将是既"button""number"相似的东西。 这将是输入类型的交集。 由于输入类型是可区分并集的成员,因此两个不兼容成员的交集被简化为never类型。

同样,编译器担心也许fn会接受类似"number"的输入,但item结果会"button"类似,反之亦然。 这不会发生,因为fnitem相关,但这种相关性并不表示在fnitem的类型中。编译器无法验证两个联合类型是否相关的此问题是 microsoft/TypeScript#30581 的主题。

在 TypeScript 4.6 之前,我会告诉你只使用类型断言来抑制错误,或者如果你真的关心类型安全而不是便利性,我会写一些冗余代码。 没有比这更好的了。


TypeScript 4.6 引入了一些在 microsoft/TypeScript#47109 中实现的索引访问改进,这允许我们重构代码以根据分发对象类型表示FooterFormElementfooterFormElementDictionary,以便有问题的函数调用可以作为泛型函数成功。

这是重构。 首先,我们采用原始FooterFormElement并将其重构为键值映射类型,其中原始type属性现在是键,其余属性是新的值类型:

interface FooterFormElementMap {
button: { buttonStuff: string },
number: { numberStuff: string }
}

然后我们可以将FooterFormElement编写为分发对象类型,这是一个映射类型,您可以立即索引到该类型中以获取所有属性类型的并集:

type FooterFormElement<K extends keyof FooterFormElementMap =
keyof FooterFormElementMap> =
{ [P in K]: { type: P } & FooterFormElementMap[P] }[K];

如果你只是写FooterFormElement你会得到一些与以前相同的联合类型:

type Test = FooterFormElement;
/* type Test = 
({ type: "number"; } & { numberStuff: string; }) | 
({ type: "button"; } & { buttonStuff: string; }) */

如果你写FooterFormElement<"button">FooterFormElement<"number">,你只会得到你关心的工会成员。 这使我们可以将FooterFormElement<K>编写为泛型类型。

最后,我们在FooterFormElementMap键中根据每个KFooterFormElement<K>重写footerFormElementDictionary类型:

declare const footerFormElementDictionary: {
[K in keyof FooterFormElementMap]: (
element: FooterFormElement<K>
) => HTMLElement;
}

事实证明,这种表示形式很重要,并且允许编译器查看footerFormElementDictionary中的属性与泛型KFooterFormElement类型的值之间的关系。

最后,我们可以将之前有问题的函数转换为通用工作版本:

function solution<K extends keyof FooterFormElementMap>(item: FooterFormElement<K>) {
const fn: (element: FooterFormElement<K>) => HTMLElement =
footerFormElementDictionary[item.type];
// item: FooterFormElement<K>
return fn(item) // okay!
}

这是有效的,因为fn被视为一个需要FooterFormElement<K>的函数,而item被视为一个FooterFormElement<K>。 因此,如果你有一个FooterFormElement数组,你可以将map()与泛型回调一起使用,它就可以工作了:

function MakeFooterForm(elements: FooterFormElement[]): HTMLElement[] {
return elements.map(<K extends keyof FooterFormElementMap>(
item: FooterFormElement<K>) => footerFormElementDictionary[item.type](item) //   
);
}

万岁!


为了清楚起见,如果我们将footerFormElementDictionary的类型注释重写为结构上等效的版本,而不提及FooterFormElement<K>,这一切都会中断:

declare const badDictionary: {
button: (element: { type: "button"; } & { buttonStuff: string; }) => HTMLElement;
number: (element: { type: "number"; } & { numberStuff: string; }) => HTMLElement;
}
function problemAgain<K extends keyof FooterFormElementMap>(item: FooterFormElement<K>) {
return badDictionary[item.type](item); // error!
// ---------------------------> ~~~~
// Argument is not assignable to parameter of type 'never'
}

如您所见,函数和参数类型之间的相关性是脆弱的,并且取决于它们类型定义之间的连接。 所以要小心。

操场链接到代码

最新更新