如何指定一个接口必须至少有两个特定类型的属性
How to specify an interface that must have at least two properties of specific types
这是 select 框的选项。每个选项都必须有一个字符串 属性 作为 id,另一个 属性 代表将要显示的内容——在本例中是 ReactNode
,但是,我不在乎这些只是所谓的有两个属性符合此标准。我试过以下方法:
type Node = string | Array<string>;
interface SelectOption {
[id: string]: string;
[el: string]: Node;
}
然而,当我尝试使用此 Typescript 时,会产生以下错误:Duplicate index signature for type 'string'
.
这不会产生错误:
export interface SelectOption {
[index: string]: string | Node;
}
但是太宽松了,因为它会匹配一个只有一个的对象 属性(也是多余的,因为 ReactNode 包含字符串)。
有没有办法为两个未命名的属性指定类型?
我强烈建议您将数据结构重构为一种能够很好地适应 TypeScript 类型系统和 JavaScript 运行 时代的格式。这是我希望 SelectOption
具有的形状:
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
现在您确切地知道每个值的键是什么了。如果您需要处理这些事情之一,您可以轻松地做到这一点:
function processSelectOption(selectOption: SelectOption) {
console.log("id at key " + selectOption.idKey +
" with string value \"" + selectOption.idValue + "\"");
console.log("el at key " + selectOption.elKey +
" with Node value " + JSON.stringify(selectOption.elValue));
}
processSelectOption({ idKey: "id", idValue: "xyz", elKey: "el", elValue: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
将此与您需要对当前数据结构执行的操作进行比较,其中您所知道的是某些键的值类型为 string
,而其他一些键的值类型为 Node
:
function processSelectOption(selectOption: any) {
// for now, let's use any ---------------> ^^^
function isString(x: any): x is string { return typeof x === "string" }
function isNode(x: any): x is Node {
return ["string", "function"].includes(typeof x) ||
(Array.isArray(x) && x.every(w => ["string", "function"].includes(typeof w)));
}
function findSelectOptionData(selectOption: any) {
for (const idKey in selectOption) {
for (const elKey in selectOption) {
if (elKey === idKey) continue;
const idValue = selectOption[idKey];
if (!isString(idValue)) continue;
const elValue = selectOption[elKey];
if (!isNode(elValue)) continue;
return { idKey, idValue, elKey, elValue };
}
}
return;
}
const selectOptionData = findSelectOptionData(selectOption);
if (!selectOptionData) throw new Error("COULDN'T FIND IT");
// now selectOptionData is the same as the SelectOption I proposed above
console.log("id at key " + selectOptionData.idKey +
" with string value \"" + selectOptionData.idValue + "\"");
console.log("el at key " + selectOptionData.elKey +
" with Node value " + JSON.stringify(selectOptionData.elValue));
}
了解我们如何编写 运行 时间测试来识别 string
和 Node
值,因为它们可以在任何属性上。我们还必须遍历 selectOption
的所有属性对,以找到一个 string
和另一个 Node
。 (因此,如果 selectOption
有键,那么您将迭代 O( ²) 个元素来识别属性。)完成所有这些操作后,您终于从SelectOption
我最初提出的接口:
processSelectOption({ id: "xyz", el: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
然后即使你这样做了,你也可能会得到意想不到的结果:
processSelectOption({ el: "node", id: "str" });
// id at key el with string value "node"
// el at key id with Node value "str"
由于 string
扩展了 Node
,因此无法查看一对字符串属性并弄清楚哪个“应该”是 id,哪个应该是元素。所以你必须做大量的处理才能到达一个对应该做什么有歧义的地方。随着属性的增加,这种歧义会变得更糟:
processSelectOption({ foo: 123, bar: "baz", id: "str", el: node });
// id at key bar with string value "baz"
// el at key id with Node value "str"
在不知道你的完整用例的情况下,我不能确定,但从外面看,这样的数据结构应该是 non-starter。那只是在看 运行 时间。
在类型系统中,同样存在奇怪的歧义。该语言并不是真正适合计算存在多少属性,以及是否有一个 A
类型的 属性 和一些不同的 B
类型的 属性。当然没有特定的类型可以捕捉到这个概念,所以写 interface SelectOption {/*...*/}
或 type SelectOption = ...
的任何希望都破灭了。
您可以将此表达为对类型的一种约束。如果你有一个候选类型 T
,你可以编写一个名为 AsValidSelectOption<T>
的 generic 类型,它接受候选类型 T
并产生一个有效的 SelectOption
类型在某种 ill-defined 意义上“接近”T
。如果 T
有效,那么我们要确保 T extends AsValidSelectOption<T>
。如果 T
无效,那么我们希望 AsValidSelectOption<T>
是“接近”T
的有效内容,以便错误消息以 user-friendly-ish 方式提及错误。
让我们现在研究一下:
首先,让我们编写 AtLeastTwoElements<K>
,其中包含一个 union of key-like types L
and evaluates to the unknown
top type if there are at least two elements in the K
union, or else the never
bottom type id 少于两个元素:
type AtLeastTwoElements<K extends PropertyKey> =
{ [P in K]: { [Q in Exclude<K, P>]: unknown }[Exclude<K, P>] }[K];
这是一个嵌套的 mapped type where in the inner types we use the Exclude
utility type,用于从 K
中连续删除键。如果我们可以这样做一次并且仍然有剩余的密钥,那么该联合中至少有两个密钥。 AtLeastTwoElements<"a" | "b">
的计算结果为 {a: {b: unknown}["b"], b: {a: unknown}["a"]}["a" | "b"]
即 {a: unknown, b: unknown}["a" | "b"]
即 unknown
。但是 AtLeastTwoElements<"a">
是 {a: {}[never], b: {}[never]}["a" | "b"]
即 {a: never, b: never}["a" | "b"]
即 never
。 AtLeastTwoElements<never>
是 {}[never]
即 never
.
然后,我们编写 ValidSelectOptionsWithKeys<K>
,它采用类键类型 K
的联合,并使用这些键生成所有可能的有效 SelectOption
类型的大联合:
type ValidSelectOptionsWithKeys<K extends PropertyKey> = { [P in K]:
Record<P, Node> & { [Q in Exclude<K, P>]: Record<Q, string> }[Exclude<K, P>]
}[K] extends infer O ? O extends any ? { [P in keyof O]: O[P] } : never : never;
这可能看起来很复杂,但它实际上与上面 findSelectOptionData()
的工作方式非常相似,通过迭代每个键并将其视为 Node
,然后迭代每个剩余的键和将其视为 string
。如果恰好有两个键 "a" | "b"
那么这将被评估为 {a: {a: Node}&{b: {b: string}}["b"], b: {b: Node}&{a: {a: string}["a"]}}["a" | "b"]
是 {a: {a: Node, b: string}, b: {b: Node, a: string}}["a" | "b"]
是 {a: Node, b: string} | {a: string, b: Node}
。可能性的数量随着 K
中条目的数量而增加。对于三个键,你有类似 {a: Node, b: string} | {a: Node, c: string} | {b: Node, a: string} | {b: Node, c: string} | {c: Node, a: string} | {c: Node, b: string}
的东西。因此,如果 K
有元素,则生成的类型是 O( ²) 个元素的并集。
最后我们构建 AsValidSelectOption<T>
:
type AsValidSelectOption<T extends object> =
unknown extends AtLeastTwoElements<keyof T> ? ValidSelectOptionsWithKeys<keyof T> :
T & (
"anotherProp" extends keyof T ? { someOtherProp: Node | string } :
{ anotherProp: Node | string }
);
如果T
至少有两个元素,那么我们评估ValidSelectOptionsWithKeys<keyof T>
,如果有效,最好将T
赋值给它。如果 T
的元素少于两个,那么我们评估 T & {anotherProp: Node | string}
,其中 T
几乎肯定会无法扩展,并且错误消息会抱怨 anotherProp
丢失。哦,除非你真的碰巧把你的一把钥匙命名为anotherProp
,否则我们会抱怨someOtherProp
。这可能不太可能,但至少我们已经涵盖了基础。
为了测试某些提议的类型 T
的值是否扩展 AsValidSelectOption<T>
,我们需要一个通用的辅助函数来传递它,因为只有通用函数会推断 T
我们而不是强迫我们手动指定它。这是函数 asSelectOption
:
const asSelectOption =
<T extends object>(
opt: T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
) => opt as T;
理想情况下我想写 <T extends AsValidSelectOption<T>>(opt: T) => opt
,但这是一个循环约束。相反,我们只将 T
约束为 object
,然后让 opt
成为条件类型 T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
。这会使编译器选择 T
作为 opt
的类型,然后对其进行测试。这是一个推理技巧。
所以那个为了捕捉“一个 string
类型的 属性 和一些不同的 Node
类型的 属性 的概念,我们付出了很多疯狂的努力。让我们至少看看它是否有效:
declare const node: Node;
const okay0 = asSelectOption({ a: "", b: node });
const okay1 = asSelectOption({ x: node, y: "" });
const okay2 = asSelectOption({ g: "", h: "" });
const okay3 = asSelectOption({ a: "", b: node, c: 123 })
const bad0 = asSelectOption({ a: "", b: 1 }); // number is not Node
const bad1 = asSelectOption({ a: node, b: node }); // error!
// Argument of type '{ a: Node; b: Node; }' is not assignable to
// parameter of type '{ a: Node; b: string; } | { b: Node; a: string; }'
const bad2 = asSelectOption({}) // Property 'anotherProp' is missing
const bad3 = asSelectOption({ a: "" }) // Property 'anotherProp' is missing
const bad4 = asSelectOption({ anotherProp: "" }) // Property 'someOtherProp' is missing
嗯,至少这样很好。 okay*
行编译没有错误,因为每个对象都符合您的约束。由于某种原因,bad*
行中存在错误。万岁,我猜!但是哦,代价是什么?
好了。如果您在编译时和 运行 时都经历了很多疯狂的循环,您最终会得到一个模棱两可、脆弱且令人困惑的实现,它会强制执行您的约束并处理(检查注释)四个值。如果将数据结构重构为
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
然后你在编译时和 运行 时都有一个简单的任务,其中四个相关信息总是在静态已知的地方,并且实现是健壮的。也许您的用例确实使 hoop-jumping 比重构更令人满意,但同样,从外部来看,我会非常警惕其中包含 asSelectOption()
之类的项目。
这是 select 框的选项。每个选项都必须有一个字符串 属性 作为 id,另一个 属性 代表将要显示的内容——在本例中是 ReactNode
,但是,我不在乎这些只是所谓的有两个属性符合此标准。我试过以下方法:
type Node = string | Array<string>;
interface SelectOption {
[id: string]: string;
[el: string]: Node;
}
然而,当我尝试使用此 Typescript 时,会产生以下错误:Duplicate index signature for type 'string'
.
这不会产生错误:
export interface SelectOption {
[index: string]: string | Node;
}
但是太宽松了,因为它会匹配一个只有一个的对象 属性(也是多余的,因为 ReactNode 包含字符串)。
有没有办法为两个未命名的属性指定类型?
我强烈建议您将数据结构重构为一种能够很好地适应 TypeScript 类型系统和 JavaScript 运行 时代的格式。这是我希望 SelectOption
具有的形状:
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
现在您确切地知道每个值的键是什么了。如果您需要处理这些事情之一,您可以轻松地做到这一点:
function processSelectOption(selectOption: SelectOption) {
console.log("id at key " + selectOption.idKey +
" with string value \"" + selectOption.idValue + "\"");
console.log("el at key " + selectOption.elKey +
" with Node value " + JSON.stringify(selectOption.elValue));
}
processSelectOption({ idKey: "id", idValue: "xyz", elKey: "el", elValue: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
将此与您需要对当前数据结构执行的操作进行比较,其中您所知道的是某些键的值类型为 string
,而其他一些键的值类型为 Node
:
function processSelectOption(selectOption: any) {
// for now, let's use any ---------------> ^^^
function isString(x: any): x is string { return typeof x === "string" }
function isNode(x: any): x is Node {
return ["string", "function"].includes(typeof x) ||
(Array.isArray(x) && x.every(w => ["string", "function"].includes(typeof w)));
}
function findSelectOptionData(selectOption: any) {
for (const idKey in selectOption) {
for (const elKey in selectOption) {
if (elKey === idKey) continue;
const idValue = selectOption[idKey];
if (!isString(idValue)) continue;
const elValue = selectOption[elKey];
if (!isNode(elValue)) continue;
return { idKey, idValue, elKey, elValue };
}
}
return;
}
const selectOptionData = findSelectOptionData(selectOption);
if (!selectOptionData) throw new Error("COULDN'T FIND IT");
// now selectOptionData is the same as the SelectOption I proposed above
console.log("id at key " + selectOptionData.idKey +
" with string value \"" + selectOptionData.idValue + "\"");
console.log("el at key " + selectOptionData.elKey +
" with Node value " + JSON.stringify(selectOptionData.elValue));
}
了解我们如何编写 运行 时间测试来识别 string
和 Node
值,因为它们可以在任何属性上。我们还必须遍历 selectOption
的所有属性对,以找到一个 string
和另一个 Node
。 (因此,如果 selectOption
有键,那么您将迭代 O( ²) 个元素来识别属性。)完成所有这些操作后,您终于从SelectOption
我最初提出的接口:
processSelectOption({ id: "xyz", el: node });
// id at key id with string value "xyz"
// el at key el with Node value ["a","b","c"]
然后即使你这样做了,你也可能会得到意想不到的结果:
processSelectOption({ el: "node", id: "str" });
// id at key el with string value "node"
// el at key id with Node value "str"
由于 string
扩展了 Node
,因此无法查看一对字符串属性并弄清楚哪个“应该”是 id,哪个应该是元素。所以你必须做大量的处理才能到达一个对应该做什么有歧义的地方。随着属性的增加,这种歧义会变得更糟:
processSelectOption({ foo: 123, bar: "baz", id: "str", el: node });
// id at key bar with string value "baz"
// el at key id with Node value "str"
在不知道你的完整用例的情况下,我不能确定,但从外面看,这样的数据结构应该是 non-starter。那只是在看 运行 时间。
在类型系统中,同样存在奇怪的歧义。该语言并不是真正适合计算存在多少属性,以及是否有一个 A
类型的 属性 和一些不同的 B
类型的 属性。当然没有特定的类型可以捕捉到这个概念,所以写 interface SelectOption {/*...*/}
或 type SelectOption = ...
的任何希望都破灭了。
您可以将此表达为对类型的一种约束。如果你有一个候选类型 T
,你可以编写一个名为 AsValidSelectOption<T>
的 generic 类型,它接受候选类型 T
并产生一个有效的 SelectOption
类型在某种 ill-defined 意义上“接近”T
。如果 T
有效,那么我们要确保 T extends AsValidSelectOption<T>
。如果 T
无效,那么我们希望 AsValidSelectOption<T>
是“接近”T
的有效内容,以便错误消息以 user-friendly-ish 方式提及错误。
让我们现在研究一下:
首先,让我们编写 AtLeastTwoElements<K>
,其中包含一个 union of key-like types L
and evaluates to the unknown
top type if there are at least two elements in the K
union, or else the never
bottom type id 少于两个元素:
type AtLeastTwoElements<K extends PropertyKey> =
{ [P in K]: { [Q in Exclude<K, P>]: unknown }[Exclude<K, P>] }[K];
这是一个嵌套的 mapped type where in the inner types we use the Exclude
utility type,用于从 K
中连续删除键。如果我们可以这样做一次并且仍然有剩余的密钥,那么该联合中至少有两个密钥。 AtLeastTwoElements<"a" | "b">
的计算结果为 {a: {b: unknown}["b"], b: {a: unknown}["a"]}["a" | "b"]
即 {a: unknown, b: unknown}["a" | "b"]
即 unknown
。但是 AtLeastTwoElements<"a">
是 {a: {}[never], b: {}[never]}["a" | "b"]
即 {a: never, b: never}["a" | "b"]
即 never
。 AtLeastTwoElements<never>
是 {}[never]
即 never
.
然后,我们编写 ValidSelectOptionsWithKeys<K>
,它采用类键类型 K
的联合,并使用这些键生成所有可能的有效 SelectOption
类型的大联合:
type ValidSelectOptionsWithKeys<K extends PropertyKey> = { [P in K]:
Record<P, Node> & { [Q in Exclude<K, P>]: Record<Q, string> }[Exclude<K, P>]
}[K] extends infer O ? O extends any ? { [P in keyof O]: O[P] } : never : never;
这可能看起来很复杂,但它实际上与上面 findSelectOptionData()
的工作方式非常相似,通过迭代每个键并将其视为 Node
,然后迭代每个剩余的键和将其视为 string
。如果恰好有两个键 "a" | "b"
那么这将被评估为 {a: {a: Node}&{b: {b: string}}["b"], b: {b: Node}&{a: {a: string}["a"]}}["a" | "b"]
是 {a: {a: Node, b: string}, b: {b: Node, a: string}}["a" | "b"]
是 {a: Node, b: string} | {a: string, b: Node}
。可能性的数量随着 K
中条目的数量而增加。对于三个键,你有类似 {a: Node, b: string} | {a: Node, c: string} | {b: Node, a: string} | {b: Node, c: string} | {c: Node, a: string} | {c: Node, b: string}
的东西。因此,如果 K
有元素,则生成的类型是 O( ²) 个元素的并集。
最后我们构建 AsValidSelectOption<T>
:
type AsValidSelectOption<T extends object> =
unknown extends AtLeastTwoElements<keyof T> ? ValidSelectOptionsWithKeys<keyof T> :
T & (
"anotherProp" extends keyof T ? { someOtherProp: Node | string } :
{ anotherProp: Node | string }
);
如果T
至少有两个元素,那么我们评估ValidSelectOptionsWithKeys<keyof T>
,如果有效,最好将T
赋值给它。如果 T
的元素少于两个,那么我们评估 T & {anotherProp: Node | string}
,其中 T
几乎肯定会无法扩展,并且错误消息会抱怨 anotherProp
丢失。哦,除非你真的碰巧把你的一把钥匙命名为anotherProp
,否则我们会抱怨someOtherProp
。这可能不太可能,但至少我们已经涵盖了基础。
为了测试某些提议的类型 T
的值是否扩展 AsValidSelectOption<T>
,我们需要一个通用的辅助函数来传递它,因为只有通用函数会推断 T
我们而不是强迫我们手动指定它。这是函数 asSelectOption
:
const asSelectOption =
<T extends object>(
opt: T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
) => opt as T;
理想情况下我想写 <T extends AsValidSelectOption<T>>(opt: T) => opt
,但这是一个循环约束。相反,我们只将 T
约束为 object
,然后让 opt
成为条件类型 T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
。这会使编译器选择 T
作为 opt
的类型,然后对其进行测试。这是一个推理技巧。
所以那个为了捕捉“一个 string
类型的 属性 和一些不同的 Node
类型的 属性 的概念,我们付出了很多疯狂的努力。让我们至少看看它是否有效:
declare const node: Node;
const okay0 = asSelectOption({ a: "", b: node });
const okay1 = asSelectOption({ x: node, y: "" });
const okay2 = asSelectOption({ g: "", h: "" });
const okay3 = asSelectOption({ a: "", b: node, c: 123 })
const bad0 = asSelectOption({ a: "", b: 1 }); // number is not Node
const bad1 = asSelectOption({ a: node, b: node }); // error!
// Argument of type '{ a: Node; b: Node; }' is not assignable to
// parameter of type '{ a: Node; b: string; } | { b: Node; a: string; }'
const bad2 = asSelectOption({}) // Property 'anotherProp' is missing
const bad3 = asSelectOption({ a: "" }) // Property 'anotherProp' is missing
const bad4 = asSelectOption({ anotherProp: "" }) // Property 'someOtherProp' is missing
嗯,至少这样很好。 okay*
行编译没有错误,因为每个对象都符合您的约束。由于某种原因,bad*
行中存在错误。万岁,我猜!但是哦,代价是什么?
好了。如果您在编译时和 运行 时都经历了很多疯狂的循环,您最终会得到一个模棱两可、脆弱且令人困惑的实现,它会强制执行您的约束并处理(检查注释)四个值。如果将数据结构重构为
interface SelectOption {
idKey: string,
idValue: string,
elKey: string,
elValue: Node
}
然后你在编译时和 运行 时都有一个简单的任务,其中四个相关信息总是在静态已知的地方,并且实现是健壮的。也许您的用例确实使 hoop-jumping 比重构更令人满意,但同样,从外部来看,我会非常警惕其中包含 asSelectOption()
之类的项目。