javascriptclassoopdsltype-systems

How to model several types with parent-child relationships and chaining methods which all extend the same base class?


I am trying to create and model the types for a series of classes which can be called in a chain in order to execute a method on the deepest class. Each of these classes represents a resource, and should extend a Resource class which has some basic methods that each of these classes should possess at minimum. Each resource can also have a parent (in the tree traversal node sense) which is another Resource, or no parent at all (i.e. tree root node). This should be reflected in the Resource class being extended. Each class which extends the Resource class may be the child of a different Resource-extending class (i.e. User can have Organization as a parent but another instance of User can have as its parent some other arbitrary class which extends Resource). My end goal, for a set of example resources [Group, Organization, User] related like so Group > Organization > User, is to be able to call these resources like this:

const group = new GroupResource('someIdForConstructor')

group
  .organization('someIdForOrgConstructor')
  .user('someUserIdForUserConstructor')
  .getName()

// or go back up by getting the parents

group
  .organization('someIdForOrgConstructor')
  .user('someUserIdForUserConstructor')
  .getParent()
  .getParent()
  .getId() // returns the id of the group

I'm unsure of how to write the classes and types such that User, Organization, and Group extend Resource, while also having a parent which also extends the Resource class, all while being able to know the type of the parent.

Essentially i want to be able to "traverse" these classes as if they were each a node in a tree, and be able to go traverse all the way back up by calling parent all while knowing at each level what type the current node is (i.e. group, organization, user).

This is what I have so far; it doesn't work type-wise but it illustrates the relationships I would like to have:

export abstract class Resource<Parent extends Resource | null = null>  {
    private parent: Parent // parent is either another class which extends resource or it is null
    private id: string;

    constructor(parent: Parent, id: string) {
        this.id = id;
        this.parent = parent;
    }

    public getId(): string {
    return this.id;
  }

  public getParent(): Parent {
    return this.parent;
  }
}


export class GroupResource<T extends Resource> extends Resource<T> {

    constructor(parent: T, id: string) {
        super(parent, id)
    }
    
    public organization(id: string): OrganizationResource<GroupResource<T>> {
        return new OrganizationResource<GroupResource<T>>(this, id)
    }
}



export class OrganizationResource<T extends Resource> extends Resource<T> {

    constructor(parent: T, id: string) {
        super(parent, id)
    }
    
    public form(id: string): UserResource<OrganizationResource<T>> {
        return new UserResource<OrganizationResource<T>>(this, id)
    }
}


export class UserResource<T extends Resource> extends Resource<T> {

    private name: string
    constructor(parent: T, id: string) {
        super(parent, id)
        this.name = "John"
    }
    
    public getName(): string {
        return this.name;
    }
}

Solution

  • Only from some of the OP's class implementation details and the OP's use cases one could get an idea of what the OP's type system should be capable of.

    Its description is as follows

    // module scope.
    
    
    // shadowed by the module's scope.
    function getLookupKey(resource) {
      let key = String(resource.id ?? '');
      while (
        (resource = resource.parent) &&
        (resource instanceof Resource)
      ) {
        key = [String(resource.id ?? ''), key].join('_')
      }
      return key;
    }
    const resourcesLookup = new Map;
    
    
    class Resource {
      #parent;
      #id;
    
      // `[parent, id]` signature had to be 
      // switched to [id, parent] due to the
      // `parent`-less instantiation of e.g.
      // `new GroupResource('someGroupId');`
    
      constructor(id = '', parent = null) {
        const lookupKey = getLookupKey({ id, parent });
        const resource = resourcesLookup.get(lookupKey);
    
        // GUARD ... instantiate an `id` and `parent.id`
        //           (and so on) specific resource type
        //           just once.
        if (resource) {
    
          return resource;
        }
        this.#parent = parent;
        this.#id = String(id);
    
        // any newly created `Resource` instance will
        // be stored under the resource type's specific
        // lookup-key which relates to its parent-child
        // relationship(s) / hierarchy(ies).
        resourcesLookup.set(lookupKey, this);
      }
    
      // the protoype methods `getParent` and `getId`
      // both got implemented as getters for both a
      // `Resource` instance's prototype properties ...
      // ... `parent` and `id`.
    
      get parent() {
        return this.#parent;
      }
      get id() {
        return this.#id;
      }
    }
    
    class GroupResource extends Resource {
      // for keeping track of all of a group-type's
      // related orga-type children.
      #orgaTypes = new Map;
    
      constructor(id, parent) {
        super(id, parent);
      }
    
      organization(id = '') {
        let child = this.#orgaTypes.get(id);
        if (!child) {
    
          child = new OrganizationResource(id, this);
          this.#orgaTypes.set(id, child);
        }
        return child;
      }
    }
    class OrganizationResource extends Resource {
      // for keeping track of all of an orga-type's
      // related user-type children.
      #userTypes = new Map;
    
      constructor(id, parent) {
        // an orga-type's parent has to be a group-type.
        if (!(parent instanceof GroupResource)) {
          return null;
        }
        super(id, parent);
      }
    
      // originaly was `form` but must be `user` in order
      // to meet the OP's original chaining example.
    
      user(id = '') {
        let child = this.#userTypes.get(id);
        if (!child) {
    
          child = new UserResource(id, this/*, name */);
          this.#userTypes.set(id, child);
        }
        return child;
      }
    }
    class UserResource extends Resource {
      #name;
    
      constructor(id, parent, name = 'John') {
        // a user-type's parent has to be an orga-type.
        if (!(parent instanceof OrganizationResource)) {
          return null;
        }
        super(id, parent);
    
        this.#name = String(name);
      }
    
      // the protoype method `getName` got implemented
      // as getter for a `Resource` instance's prototype
      // property ... `name`.
    
      get name() {
        return this.#name;
      }
    }
    
    const group = new GroupResource('someGroupId'); // - created(1).
    
    const userName = group
      .organization('someOrgaId') // - created(2) at 1st access time.
      .user('someUserId')         // - created(3) at 1st access time.
      .name; // 'John'
    
    const userId = group
      .organization('someOrgaId') // - orga-type already exists.  
      .user('someUserId')         // - user-type already exists.
      .id; // 'someUserId'
    
    const orgaId = group
      .organization('someOrgaId')
      .user('someUserId')
      .parent
      .id; // 'someOrgaId'
    
    const groupId = group
      .organization('someOrgaId')
      .user('someUserId')
      .parent
      .parent
      .id; // 'someGroupId'
    
    const anotherGroup =
      new GroupResource('anotherGroupId'); // - created(4).
    
    const anotherOrgaId = anotherGroup
      .organization('anotherGroupOrgaId')  // - created(5) at 1st access time.
      .id;
    const anotherGroupId = anotherGroup
      .organization('anotherGroupOrgaId')  // - orga-type already exists.  
      .parent
      .id;
    const resourceInstanceEntriesList = [...resourcesLookup.entries()];
    
    console.log({
    
      userName,
      userId,
      orgaId,
      groupId,
    
      anotherOrgaId,
      anotherGroupId,
    
      resourceInstanceEntriesList,
    });
    .as-console-wrapper { min-height: 100%!important; top: 0; }