Search code examples
typescriptconditional-types

Force one out of many optional properties to be in the instance of a type


I have this type or interface:

export interface CreateProjectAction {
  type: typeof CREATE_PROJECT | typeof CREATE_PROJECT_ERROR
  project?: {title:string, content:string}
  err?: Error
}

project and err are optional. I would like them to preserve their name props, so that they can be passed in or required as such in the type instance, but, to have one of them compulsorily, so that this would give an error as one of the either two optional project or err neeeds to be passed in.

function Foo(createAction: CreateProjectAction) {
  console.log(createAction);
}

Foo({ type: 'CREATE_PROJECT_ERROR' } ); //ERROR, need to pass in either the project or the err prop

As said, as I would like to preserve the prop names because the optionalArg name would no be very descriptive, so I discard the option:

export interface CreateProjectAction {
  type: typeof CREATE_PROJECT | typeof CREATE_PROJECT_ERROR
  optionalArg?: {title:string, content:string} | Error
}

Solution

  • With a bit of black magic, you can force "one and only one property defined" depending on the type of type. First of all, we need to establish a relationship between members of the type union and the required properties of CreateProjectAction. The easiest way to do that is to create a lookup where keys are of type type and values are keyof CreateProjectAction, like:

    {
      "CREATE_PROJECT": "project", 
      "CREATE_PROJECT_ERROR" : "err"
    }
    

    Next, let's allow the lookup to be partial with this small helper utilizing key remapping feature from 4.1 (undefined properties are filtered out from the lookup):

    type OnlyDefined<T> =  {
      [ P in keyof T as T[P] extends undefined ? never : P ] : T[P]
    };
    

    Finally, the incantation:

    type RequireIf<T extends { type: ActionType }, R extends { [ P in ActionType ] ?: keyof T }> = {
      [ P in keyof OnlyDefined<R> as P extends T["type"] ? OnlyDefined<R>[P] & string : never ] : OnlyDefined<R>[P] extends keyof T ? Exclude<T[OnlyDefined<R>[P]], undefined> : never
    } & Omit<T,  OnlyDefined<R>[ keyof OnlyDefined<R> ] & string >
    
    1. T is constrained to { type: ActionType } so as we are able to index T["type"]
    2. OnlyDefined<R>[P] gives us the correct property of T that should be present
    3. Exclude<T[OnlyDefined<R>[P]], undefined> forces the property to be defined (as a consequence of allowing partial lookups, the type of property is <type> | undefined)

    Testing confirms everything is working as expected:

    type CreateProjectAction<T extends ActionType> = RequireIf<{
      type: T
      project?: {title:string, content:string}
      err?: Error
    }, {
      "CREATE_PROJECT": "project", 
      "CREATE_PROJECT_ERROR" : "err"
    }>
    
    const project: CreateProjectAction<"CREATE_PROJECT"> = { type: "CREATE_PROJECT", project: { content: "Yay!", title: "Vote" } };
    
    const error: CreateProjectAction<"CREATE_PROJECT_ERROR"> = { type: "CREATE_PROJECT_ERROR", err: new RangeError("Nay!") };
    
    const invalid: CreateProjectAction<"CREATE_PROJECT"> = { type: "CREATE_PROJECT", err: new Error("Nope") }; //'err' does not exist in type
    

    Playground