Search code examples
javascripttypescriptgenericstypescript-typingstypescript-generics

How to achieve at least one property required in object recursively for nested objects in typescript


For a project I'm working on, I required my objects to be defined with at least one of the property from the object for example below is types that defines these options

type DraggableOptions = {
  identifier?: {
    id?: string;
    type?: string;
  };
  modifiers?: {
    dropEffect?: "copy" | "move";
  };
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  data?: any;
}

export type DroppableOptions = {
  identifier: {
    id?: string;
    type?: string[];
  };
  modifiers?: {
    highlight?: {
      on: "dragmove" | "dragover";
      class: string;
    };
  };
};

export type GlobalOptions = {
  draggableOptions?: DraggableOptions;
  droppableOptions?: DroppableOptions;
};

As all the properties are optional(because I also have default options) user can just pass a empty object {} when passing the DraggableOptions which I want to restrict to define at least one property from the object.

I also found a solution for this in the following stack overflow question but it does not work for nested objects which is my requirement. So based on the solution I have found I tried to write a recursive one also but could not get it right.

I found these two particular solution to be working in my case.

type AtLeastOne<T> = {
  [K in keyof T]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<keyof T, K>>>;
}[keyof T];
type AtLeastOne<T, U = { [K in keyof Required<T>]: Pick<Required<T>, K> }> = Partial<T> &
  U[keyof U];

And I tried the following solution for recursive but they are not working. I want to make not only the direct properties to follow this rule but also the nested objects.

type RecursiveAtLeastOne<T> = {
  [K in keyof T]: T[K] extends object ? AtLeastOne<T[K]> : T[K];
};

and

type RecursiveAtLeastOne<T> = {
  [K in keyof T]: T[K] extends Record<string, any> ? AtLeastOne<T[K]> : T[K];
};

Can any please guide me what I'm doing wrong and what would be the right solution for this problem? Thank you so much in advance.

Btw as I am not able to write the recursive version correctly I'm using the following approach to make nested objects at least on required

export type DraggableOptions = AtLeastOne<{
  identifier?: AtLeastOne<{
    id?: string;
    type?: string;
  }>;
  modifiers?: AtLeastOne<{
    dropEffect?: "copy" | "move";
  }>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  data?: any;
}>;

Below is the usage example

// This redrop class set's the global draggable and droppable options
// It accepts draggableOptions and droppableOptions but not required 
// for the user to always define these options
const redrop = new Redrop({
  draggableOptions: {
    identifier: {
      id: "id",
    },
  },
});
const draggable = document.querySelector("#item1");

// makeDraggable accepts 2nd parameter as draggableOptions
redrop.makeDraggable(draggable, {
  identifier: {
    id: "id",
  },
});

// draggableOptions are optional and user can just do the following
redrop.makeDraggable(draggable);

// But as I have made all properties as optional(?) user can 
// also do something like this -

const redrop = new Redrop({}) // empty object without any options
// or
const redrop = new Redrop({
draggableOptions: {} // no options defined
droppableOptions: {}
})

// or 
redrop.makeDraggable(draggable, {});



Solution

  • After tinkering a bit I found a solution which might not be the best but as of now working as expected.

    To recap, I had a few types in which every property is optional so it was possible for the user to just pass a {} object as all properties are optional user need to not to specify any property. To solve this I found a solution on the stack overflow itself which is following

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

    This allowed to create a constraint to provide at least one property when defining the object instead of just a "{}" empty object. It worked but it does not work on nested properties so if the object had nested object and I want to enforce the same rule, the current AtLeastOne type did not solve this issue.

    To fix this issue I created the recursive type of AtLeastOne type which is following

    type RecursiveAtLeastOne<T> = {
      [K in keyof T]: T[K] extends object ? AtLeastOne<T[K]> : T[K];
    };
    

    Initially I thought this is not working because all my properties were optional

    type DraggableOptions = {
      identifier?: {
        id?: string;
        type?: string;
      };
      modifiers?: {
        dropEffect?: "copy" | "move";
      };
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      data?: any;
    }
    

    But when I removed the (?) to make the properties required the RecursiveAtLeastOne started working as I wanted with just one catch. It only applied the rule to the nested objects for example "identifier" and "modifier" which are nested in the main DraggableOptions this meant I can not just define {identifier: {}} or {modifier: {}} I needed to provide at least one property but it was still possible to just define empty draggable option like this redrop.makeDraggable(draggable, {});

    Finally to fix this I just wrapped the RecursiveAtLeastOne in another AtLeastOne like below

    type RecursiveAtLeastOne<T> = AtLeastOne<{
      [K in keyof T]: T[K] extends object ? AtLeastOne<T[K]> : T[K];
    }>;
    

    So to summarise to apply the at least one rule I need to make all the properties required by default so that I can use the AtLeastOne and RecursiveAtLeastOne type to make them optional but also making sure at least one property is always defined.