typescript interface require one of two properties to exist

Solution 1:

With the help of the Exclude type which was added in TypeScript 2.8, a generalizable way to require at least one of a set of properties is provided is:

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]

And a partial but not absolute way to require that one and only one is provided is:

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]

Here is a TypeScript playground link showing both in action.

The caveat with RequireOnlyOne is that TypeScript doesn't always know at compile time every property that will exist at runtime. So obviously RequireOnlyOne can't do anything to prevent extra properties it doesn't know about. I provided an example of how RequireOnlyOne can miss things at the end of the playground link.

A quick overview of how it works using the following example:

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}
    

Solution 2:

Not with a single interface, since types have no conditional logic and can't depend on each other, but you can by splitting the interfaces:

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;