如何指定一个接口必须至少有两个特定类型的属性

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));
}

了解我们如何编写 运行 时间测试来识别 stringNode 值,因为它们可以在任何属性上。我们还必须遍历 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"]neverAtLeastTwoElements<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() 之类的项目。

Playground link to code