3

这是用于选择框的选项。每个选项都必须有一个用作 id 的字符串属性,以及另一个表示将要显示的内容的属性——在这种情况下 a 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 包含字符串)。

有没有办法为两个未命名的属性指定类型?

4

1 回答 1

1

我强烈建议您将数据结构重构为与 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"]

将此与您需要对当前数据结构执行的操作进行比较,您所知道的是某个键的值是 type string,而其他键的值是 type 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以找到一个是 astring和一个不同的是 a 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"

由于stringextends 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"

在不了解您的完整用例的情况下,我无法确定,但从外部看来,这样的数据结构应该是不可能的。这只是在运行时。


在类型系统中,同样存在奇怪的歧义。该语言并不真正适合计算存在多少属性以及是否存在一个类型的属性A和一些不同的类型属性B。肯定没有特定的类型可以捕捉到这个概念,所以任何写作的希望interface SelectOption {/*...*/}type SelectOption = ...没有了。

可以将其表示为对类型的一种约束。如果你有一个候选类型T,你可以编写一个名为的泛型AsValidSelectOption<T>类型,它接受候选类型T并产生一个有效类型,它在某种定义不明确的意义上SelectOption“接近” 。T如果T是有效的,那么我们要确定T extends AsValidSelectOption<T>. 如果T无效,那么我们希望AsValidSelectOption<T>成为“接近”有效的东西,T以便错误消息以用户友好的方式提及错误。

现在让我们研究一下:


首先,让我们编写AtLeastTwoElements<K>一个类键类型的联合,如果联合中至少有两个元素,则计算L顶部类型,否则底部类型id 少于两个元素:unknownKnever

type AtLeastTwoElements<K extends PropertyKey> =
  { [P in K]: { [Q in Exclude<K, P>]: unknown }[Exclude<K, P>] }[K];

这是一个嵌套映射类型,在内部类型中,我们使用实用Exclude程序类型K. 如果我们可以这样做一次并且仍然有剩余的键,那么该联合中至少有两个键。 AtLeastTwoElements<"a" | "b">评估为{a: {b: unknown}["b"], b: {a: unknown}["a"]}["a" | "b"]which is {a: unknown, b: unknown}["a" | "b"]which is unknown。但是AtLeastTwoElements<"a">{a: {}[never], b: {}[never]}["a" | "b"]哪个是{a: never, b: never}["a" | "b"]哪个neverAtLeastTwoElements<never>{}[never]哪个never是。


然后,我们编写ValidSelectOptionsWithKeys<K>它采用 keylike 类型的并集,并使用这些键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()上面的工作方式非常相似,通过遍历每个键并将其视为 a Node,然后遍历所有剩余的键并将其视为 a string。如果恰好有两个键"a" | "b",那么这将被评估为类似于{a: {a: Node}&{b: {b: string}}["b"], b: {b: Node}&{a: {a: string}["a"]}}["a" | "b"]which is {a: {a: Node, b: string}, b: {b: Node, a: string}}["a" | "b"]which is的东西{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。这可能不太可能,但至少我们已经覆盖了基础。


为了测试一些提议的类型值是否为Textends 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,但这是一个循环约束。相反,我们只限制Tobject,但具有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
}

那么您在编译时和运行时都有一个简单的任务,其中四条相关信息始终位于静态已知的位置,并且实现是健壮的。也许您的用例确实使跳圈比重构更可取,但同样,从外部来看,我会非常警惕其中包含类似内容的项目asSelectOption()

Playground 代码链接

于 2022-01-30T20:34:32.717 回答