Search code examples
typescripttypescript-types

Typescript Type/Interface - if a certain property exists on an object, any other properties are invalid


This answer has helped me a lot so far, but my question is a little different. I want a type that defines several available properties, but whenever an object is created with any one of those properties, then it may not have any others. The question referenced, and all of its answers, only address the case when there are only 2 properties that are exclusionary.

Minimal reproducible example:

export interface OperatorExpression {
    equals?: any;
    lessThan?: any;
    lessThanOrEqualTo?: any;
    greaterThan?: any;
    greaterThanOrEqualTo?: any;
    contains?: any;
    // etc.
}

I want the object using this interface to only allow one of those properties on it:

const op1 = { equals: {}, greaterThanOrEqualTo: {} }; // invalid, it should only allow one property
const op2 = { lessThanOrEqualTo: {} }; // valid

Okay, this is a little contrived, but I do have a similar use case.

Following the linked answer above, and others as well, I started something like this:

export interface EqualsOperatorExpression {
    equals: any;
    lessThan?: never;
    lessThanOrEqualTo?: never;
    greaterThan?: never;
    greaterThanOrEqualTo?: never;
    contains?: never;
}

export interface LessThanOperatorExpression {
    equals?: never;
    lessThan: any;
    lessThanOrEqualTo?: never;
    greaterThan?: never;
    greaterThanOrEqualTo?: never;
    contains?: never;
}

export interface LessThanOrEqualToOperatorExpression {
    equals?: never;
    lessThan?: never;
    lessThanOrEqualTo: any;
    greaterThan?: never;
    greaterThanOrEqualTo?: never;
    contains?: never;
}

export interface GreaterThanOperatorExpression {
    equals?: never;
    lessThan?: never;
    lessThanOrEqualTo?: never;
    greaterThan: any;
    greaterThanOrEqualTo?: never;
    contains?: never;
}

export interface GreaterThanOrEqualToOperatorExpression {
    equals?: never;
    lessThan?: never;
    lessThanOrEqualTo?: never;
    greaterThan?: never;
    greaterThanOrEqualTo: any;
    contains?: never;
}

export interface ContainsOperatorExpression {
    equals?: never;
    lessThan?: never;
    lessThanOrEqualTo?: never;
    greaterThan?: never;
    greaterThanOrEqualTo?: never;
    contains: any;
}

export type OperatorExpression = 
    | EqualsOperatorExpression 
    | LessThanOperatorExpression
    | LessThanOrEqualToOperatorExpression 
    | GreaterThanOperatorExpression 
    | GreaterThanOrEqualToOperatorExpression 
    | ContainsOperatorExpression;

Now this works, but in my opinion is very clunky. If I need to add a new operator, then I have to go back through each other operator's interface, and define the one I'm adding as never.

Is there any cleaner way to do this in Typescript, or am I stuck with the mountain of boilerplate I have to update on each new addition?


Solution

  • If you have an object type T and want to convert it to a union of object types each of which has exactly one property from T as required and the rest as "prohibited" (by making them optional properties of the impossible never type), you can do it this way:

    type SingleProp<T extends object> = {
      [K in keyof T]-?: (
        { [P in K]-?: T[K] } &
        { [P in keyof T as P extends K ? never : P]?: never }
      )
    }[keyof T];
    

    where we're mapping each key K to a type which is the intersection of a type with a required K property ({[P in K]-?: T[K]}) and a type with a prohibited property for everything except K ({[P in keyof T as P extends K ? never : P]?: never}), using the ? and -? mapping modifiers to make things optional/required, and key remapping to suppress K from keyof T.

    And then we index into the resulting mapped type with keyof T to get a union of the things we want.


    This produces correct but somewhat unsightly types:

    type Demo = SingleProp<{ a?: string, b?: number, c?: boolean }>;
    /* type Demo = 
      ({ a: string; } & { b?: undefined; c?: undefined; }) | 
      ({ b: number; } & { a?: undefined; c?: undefined; }) | 
      ({ c: boolean; } & { a?: undefined; b?: undefined; }) */
    

    If you want to collapse those intersections into single object types, you can use a technique described at How can I see the full expanded contract of a Typescript type? :

    type SingleProp<T extends object> = {
      [K in keyof T]-?: (
        { [P in K]-?: T[K] } &
        { [P in keyof T as P extends K ? never : P]?: never }
      ) extends infer O ? { [P in keyof O]: O[P] } : never
    }[keyof T];
    
    type Demo = SingleProp<{ a?: string, b?: number, c?: boolean }>;
    /* type Demo = 
      { a: string; b?: undefined; c?: undefined; } | 
      { b: number; a?: undefined; c?: undefined; } | 
      { c: boolean; a?: undefined; b?: undefined; }*/
    

    And these might also not be ideal since the keys are reordered; if that matters (and it probably doesn't) you could rework the intersection so that each property shows up in the right order:

    type SingleProp<T extends object> = {
      [K in keyof T]-?: (
        { [P in keyof T]?: P extends K ? unknown : never } &
        { [P in K]-?: T[K] }
      ) extends infer O ? { [P in keyof O]: O[P] } : never
    }[keyof T]
    
    type Demo = SingleProp<{ a?: string, b?: number, c?: boolean }>;
    /* type Demo = 
      { a: string; b?: undefined; c?: undefined; } | 
      { a?: undefined; b: number; c?: undefined; } | 
      { a?: undefined; b?: undefined; c: boolean; } */ 
    

    The type {a?: never, b?: unknown, c?: never} & {b: number} is the same type as {b: number} & {a?: never, c?: never}, but the former will preserve the key ordering.


    Okay, let's try on your example type:

    type OperatorExpression = SingleProp<OrigOperatorExpression>;
    
    /* type OperatorExpression = {
      equals: any;
      lessThan?: undefined;
      lessThanOrEqualTo?: undefined;
      greaterThan?: undefined;
      greaterThanOrEqualTo?: undefined;
      contains?: undefined;
    } | {
      equals?: undefined;
      lessThan: any;
      lessThanOrEqualTo?: undefined;
      greaterThan?: undefined;
      greaterThanOrEqualTo?: undefined;
      contains?: undefined;
    } | {
      equals?: undefined;
      lessThan?: undefined;
      lessThanOrEqualTo: any;
      greaterThan?: undefined;
      greaterThanOrEqualTo?: undefined;
      contains?: undefined;
    } | {
      equals?: undefined;
      lessThan?: undefined;
      lessThanOrEqualTo?: undefined;
      greaterThan: any;
      greaterThanOrEqualTo?: undefined;
      contains?: undefined;
    } | {
      equals?: undefined;
      lessThan?: undefined;
      lessThanOrEqualTo?: undefined;
      greaterThan?: undefined;
      greaterThanOrEqualTo: any;
      contains?: undefined;
    } | {
      equals?: undefined;
      lessThan?: undefined;
      lessThanOrEqualTo?: undefined;
      greaterThan?: undefined;
      greaterThanOrEqualTo?: undefined;
      contains: any;
    } */
    

    Looks good!

    Playground link to code