TypeScript接口要求存在两个属性中的一个

256
我正在尝试创建一个接口,可以拥有

export interface MenuItem {
  title: string;
  component?: any;
  click?: any;
  icon: string;
}
  1. 有没有一种方法可以要求设置componentclick?
  2. 有没有一种方法可以要求两个属性都不能被设置?

11个回答

346

在 TypeScript 2.8 中增加了 Exclude 类型的帮助下,提供了一种可推广的方法来要求一组属性中至少存在一个,即:

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
    Pick<T, Exclude<keyof T, Keys>> 
    & {
        [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
    }[Keys]

要求提供其中一个但不是全部的部分方法是:

type RequireOnlyOne<T, Keys extends keyof T = keyof T> =
    Pick<T, Exclude<keyof T, Keys>>
    & {
        [K in Keys]-?:
            Required<Pick<T, K>>
            & Partial<Record<Exclude<Keys, K>, undefined>>
    }[Keys]

这里是一个TypeScript Playground的链接,展示了两个操作的实现和运行结果。涉及到IT技术领域,需要翻译的内容如下:

使用RequireOnlyOne需要注意的是,在编译时,TypeScript并不总是知道每个属性在运行时都会存在。因此,RequireOnlyOne无法阻止它不知道的额外属性。我在playground链接的末尾提供了一个示例,说明RequireOnlyOne可能会漏掉一些东西。

以下是它的工作原理的简要概述,使用以下示例:
interface MenuItem {
  title: string;
  component?: number;
  click?: number;
  icon: string;
}

type ClickOrComponent = RequireAtLeastOne<MenuItem, 'click' | 'component'>
  1. Pick<T, Exclude<keyof T, Keys>> from RequireAtLeastOne becomes { title: string, icon: string}, which are the unchanged properties of the keys not included in 'click' | 'component'

  2. { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys] from RequireAtLeastOne becomes

    { 
        component: Required<{ component?: number }> & { click?: number }, 
        click: Required<{ click?: number }> & { component?: number } 
    }[Keys]
    

    Which becomes

    {
        component: { component: number, click?: number },
        click: { click: number, component?: number }
    }['component' | 'click']
    

    Which finally becomes

    {component: number, click?: number} | {click: number, component?: number}
    
  3. The intersection of steps 1 and 2 above

    { title: string, icon: string} 
    & 
    ({component: number, click?: number} | {click: number, component?: number})
    

    simplifies to

    { title: string, icon: string, component: number, click?: number} 
    | { title: string, icon: string, click: number, component?: number}
    

15
非常感谢您提供这些详细的例子,对我来说非常有启发性。 - Eduard
3
感谢您提供如此信息丰富和形式良好的回复。我一直在研究这个例子,以便更好地理解它。看起来,如果您给componentclick一个类型而不是any,那么具有至少一个有效属性集的对象将通过。我认为这是因为类型归约为{ title: string, icon: string, component: any} | { title: string, icon: string, click: any},该类型声明了3个属性而不是4个,其中一个是可选的。我正在尝试查找有关在映射符号中使用数组的文档,但无法找到。 - Sam R
9
请问 [K in keys]- 中的连字符(减号)是什么意思?请用通俗易懂的语言进行翻译,不要改变原文的含义或添加解释。 - Ross Brasseaux
3
它会移除原始对象 TK 可选修饰符的任何选项。参见 https://dev59.com/p1UL5IYBdhLWcg3w4raQ 至于在 [K in Keys]-?: 中的目的:我刚刚进行了一些测试,看起来它实际上对最终结果没有影响,但我将其放入只是为了确保 RequireAtLeastOne 的行为方式不受指定为 Keys 的属性最初是否可选的影响。 - KPD
11
自 TypeScript 3.5 开始,我们可以使用 Omit 辅助工具(相当于 Pick + Exclude 快捷方式)来进一步缩短“至少一个”类型的定义:type RequireAtLeastOne<T, R extends keyof T = keyof T> = Omit<T, R> & { [ P in R ] : Required<Pick<T, P>> & Partial<Omit<T, P>> }[R];。基础框架相同,因此只需要这样一条注释(快速提醒:在 [K in keys]-?: 中,-? 修饰符可放心省略:两个版本中都保留了可选性)。 - Oleg Valter is with Ukraine
显示剩余8条评论

210

不能用单一接口实现,因为类型没有条件逻辑且不能相互依赖,但可以通过拆分接口来实现:

export interface BaseMenuItem {
  title: string;
  icon: string;
}

export interface ComponentMenuItem extends BaseMenuItem {
  component: any;
}

export interface ClickMenuItem extends BaseMenuItem {
    click: any;
}

export type MenuItem = ComponentMenuItem | ClickMenuItem;

有趣。我以前从没导出过一个“类型”。那么这只是意味着 MenuItem 可以是其中之一? - Nix
21
大家好,你们如何强制在两个接口中选择一个而不是两个都选择?使用这种类型,同时拥有“component”和“click”的对象不会出错... - Daniel Ramos
17
你可以在 ComponentMenuItem 上添加 click?: never,并在 ClickMenuItem 上添加 component?: never - ethan.roday
16
有没有办法使它与参数解构兼容?如果我尝试使用 function myFunc({ title, icon, component, click }: MenuItem),会出现TS错误:类型“MenuItem”上不存在属性“component”。 类型“MenuItem”上不存在属性“click”。 - CletusW
2
这里有一个库,包含了一堆来自Sindresorhus的实用类型,称为type-fest。链接在这里 - Leela Venkatesh K
显示剩余8条评论

132

有一个更简单的解决方案。无需依赖any或复杂的conditional types(参见答案)

  1. 有没有一种方法可以要求设置组件或点击? (包含OR
type MenuItemOr = {
    title: string;
    icon: string;
} & ({ component: object } | { click: boolean }) 
// brackets are important here: "&" has precedence over "|"

let testOr: MenuItemOr;
testOr = { title: "t", icon: "i" } // error, none are set
testOr = { title: "t", icon: "i", component: {} } // ✔
testOr = { title: "t", icon: "i", click: true } // ✔
testOr = { title: "t", icon: "i", click: true, component: {} } // ✔

联合类型|)对应于包含的OR。它与非条件属性相交

使用in操作符将值缩小范围到其中一个组成部分:

if ("click" in testOr) testOr.click // works 

有没有一种方法可以要求两个属性都不能设置?(异或)
type MenuItemXor = {
    title: string;
    icon: string;
} & (
        | { component: object; click?: never }
        | { component?: never; click: boolean }
    )

let testXor: MenuItemXor;
testXor = { title: "t", icon: "i" } // error, none are set
testXor = { title: "t", icon: "i", component: {} } // ✔
testXor = { title: "t", icon: "i", click: true } // ✔
testXor = { title: "t", icon: "i", click: true, component: {} } //error,both set

基本上可以设置componentclick,另一个不应该同时添加1。TS可以将MenuItemXor制成区分联合类型,对应于XOR
这个MenuItemXorXOR条件是不能使用已接受的答案

游乐场

1 技术上讲,prop?: never 实际上被解析为 prop?: undefined,尽管前者经常用于说明。


11
如何处理返回错误 Property 'click' does not exist on type 'MenuItemOr'.testOr.click; - Eduardo Dallmann
9
你可以使用in运算符检查对象上是否存在这两个属性,以下是示例代码(Playground): - ford04
1
@NorTicUs 如果你指的是 MenuItemXor& ( ... ) 内部的前导 |:这只是一种更好的类型格式化/分隔多行的并集运算符的快捷方式。就像这里一样 - 没有任何魔法 :) - ford04
2
在我看来,与此答案相比,它更加流畅,但可读性稍有下降:https://dev59.com/0loU5IYBdhLWcg3wOlE-#61281828 - Sheraff
1
in操作符的建议非常有帮助(我希望可选链操作符(obj?.myProp)也能起作用)。 - bristweb
显示剩余8条评论

21
一种没有多个接口的替代方案是:
export type MenuItem = {
  title: string;
  component: any;
  icon: string;
} | {
  title: string;
  click: any;
  icon: string;
};

const item: MenuItem[] = [
  { title: "", icon: "", component: {} },
  { title: "", icon: "", click: "" },
  // Shouldn't this error out because it's passing a property that is not defined
  { title: "", icon: "", click: "", component: {} },
  // Does error out :)
  { title: "", icon: "" }
];

我曾在如何创建类似Partial的东西,要求设置一个属性上问过类似的问题。

上面的内容可以简化,但是否更易读并不确定。

export type MenuItem = {
  title: string;
  icon: string;
} & (
 {component: any} | {click: string}
)

请注意,这些内容都不会阻止您添加两个属性,因为TypeScript允许在使用AND/OR的对象上添加额外的属性。请参见https://github.com/Microsoft/TypeScript/issues/15447

1
你可以使用 &titleicon 提取到一个单独的类型中。 :) - Robert Koritnik

18
我使用这个:
type RequireField<T, K extends keyof T> = T & Required<Pick<T, K>>

使用方法:

let a : RequireField<TypeA, "fieldA" | "fieldB">;

这使得 fieldAfieldB 成为必填项。

1
对于其他读者:请注意,此类型不会使其中一个(或上下文)属性成为必需属性,而是两者都是(也就是说,在AND上下文中它将表现得很好)。 - Oleg Valter is with Ukraine
1
我不得不稍微调整一下解决方案,使其在这里起作用:type RequireField<T, K extends keyof T> = T | Required<Pick<T, K>>。我将交集运算符(&)更改为联合运算符(|)。 - Dami

9

我最终做的是:

export interface MenuItem {
  title: string;
  icon: string;
}

export interface MenuItemComponent extends MenuItem{
  component: any;
}

export interface MenuItemClick extends MenuItem{
  click: any;
}

然后我使用了:

 appMenuItems: Array<MenuItemComponent|MenuItemClick>;

但我希望有一种方法可以用单个接口对其进行建模。


2
实际上,微软建议应该是:<MenuItemComponent|MenuItemClick>[] - Loolooii

6
这里有一种简单的方法来实现“要么 A,要么 B,但不能同时出现”。
type MenuItem =  {
  title: string;
  component: any;
  click?: never;
  icon: string;
} | {
  title: string;
  component?: never;
  click: any;
  icon: string;
}

// good
const menuItemWithComponent: MenuItem = {
  title: 'title',
  component: "my component",
  icon: "icon"
}

// good
const menuItemWithClick: MenuItem = {
  title: 'title',
  click: "my click",
  icon: "icon"
}

// compile error
const menuItemWithBoth: MenuItem = {
  title: 'title',
  click: "my click",
  component: "my click",
  icon: "icon"
}

6

我喜欢使用Pick和包含所有属性的基本类型来建立这种条件要求。

interface MenuItemProps {
  title: string;
  component: any;
  click: any;
  icon: string;
}

export interface MenuItem =
  Pick<MenuItemProps, "title" | "icon" | "component"> |
  Pick<MenuItemProps, "title" | "icon" | "click">

这是干净且灵活的。你可以根据需要自由地进行各种复杂的断言,比如“要求所有属性、只有这两个属性或只有一个属性”,同时保持声明简单易读。


2
接口不能“=”一个类型。如果是export interface MenuItem = ...则是有效的。 - J'e
2
@J'e 我认为你的意思是 export type MenuItem = ... - Spikatrix

2

另一种解决方案:

type RequiredKeys<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;


type MenuItem2 = RequiredKeys<MenuItem, "component" | "click">;


1
这种方法结合了neverOmit。它易于理解,如果需要添加更多属性,也很容易更新。
interface Base {
  title: string;
  icon: string;
  component?: never;
  click?: never;
}

interface OnlyComponent {
  component: any;
}

interface OnlyClick {
  click: any;
}

export type MenuItem = (Omit<Base, 'component'> & OnlyComponent) | (Omit<Base, 'click'> & OnlyClick);

您可以使用in来缩小MenuItem的实例范围:
const item: MenuItem = {
  title: 'A good title';
  icon: 'fa-plus';
  component: SomeComponent;
};

//...

if('component' in item) {
  const Comp = item.component;
  //...
}


1
never选项在定义具有字符串可枚举索引的接口时,似乎也可以用于排除某些属性,例如interface Columns{ [column: string]: any; columns?: never; }以防止与包装类interface ColumnData{ columns: Columns; }混淆(真实例子;我经常忘记在传递给函数之前需要取消包装的位置,但这应该有所帮助)。我唯一关心的问题是,在运行时,我是否可能会得到一个名为“columns”的列;但由于Typescript编译为JavaScript,因此此类型检查应该在运行时丢失,对吗? - Trevortni

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接