2

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?

Seth
  • 342
  • 1
  • 4
  • 14
  • 1
    The [`ExclusiveUnion`](https://stackoverflow.com/a/74696342/21146235) in this answer may be worth a look – motto Mar 23 '23 at 20:51
  • It's hard to have an opinion without all the context, but do you really need to have each type of operator as a field on an expression interface? Could you just pass the operator itself? – Simon MᶜKenzie Mar 23 '23 at 20:51
  • 1
    @SimonMᶜKenzie it's a contrived example, though it may be flawed - the point I'm making is that in the scenario, it doesn't make sense to have an object that declares more than one of the available properties. – Seth Mar 23 '23 at 21:01
  • Thanks for the detail @Seth, but does the concept still hold? If you should only ever have one populated property, why not ditch the interface and just pass the value of the property you would have populated? – Simon MᶜKenzie Mar 23 '23 at 21:04
  • 1
    Is [this approach](https://tsplay.dev/we6ZXN) what you're looking for? If so I could write up an answer explaining; if not, what am I missing? – jcalz Mar 23 '23 at 21:11
  • @SimonMᶜKenzie I want type safety on this. I want a consumer to pick one of the properties, and as soon as they do, they may not add any other property in the same object. In my use case, the properties' types are actually of the same type as the parent object, so this can build out a kind of tree. That is about the entire detail I'm willing to get into right now about my specific use case. Of course, there are many ways to accomplish the same thing, but this is how we've chosen to approach our task. – Seth Mar 23 '23 at 21:16
  • @jcalz This approach seems to work, and I think would be worth an answer write-up – Seth Mar 23 '23 at 21:17
  • @motto This is definitely very helpful – Seth Mar 23 '23 at 21:18
  • I will write up an answer when I get the chance. – jcalz Mar 23 '23 at 21:21

2 Answers2

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

One easy way is to use a type that unions some different interfaces.

And then you need a shared property that they all have, but with unique values. In my example I use tag, but practically, it can be anything.

type OperatorExpression = 
  | { tag: 'equals', equals: any }
  | { tag: 'lessThan', lessThan: any }
  | { tag: 'lessThanOrEqualTo', lessThanOrEqualTo: any }

function example(data: OperatorExpression) {
    if (data.tag === 'equals') {
        return data.equals
    } else if (data.tag === 'lessThan') {
        return data.lessThan
    } else if (data.tag === 'lessThanOrEqualTo') {
        return data.lessThanOrEqualTo
    }
}

So then, TypeScript knows, if an object has the tag property 'equals' then it must have a corresponding equals property and so on.

pizzaisdavid
  • 455
  • 3
  • 13