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;
}
}
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
There are 4 classes, a (base) class Resource
and 3 distinct other classes GroupResource
, OrganizationResource
and UserResource
which have in common that all three do extend Resource
.
A Resource
instance carries just two private properties id
and parent
which get exposed to the outside by two equally named get
ter functions. The same applies to any of the other three (group, organization, user) possible resource types.
A GroupResource
instance does not (necessarily) carry a (valid) parent object whereas ...
the parent object of an OrganizationResource
instance has to be an instance of GroupResource
...
and the parent object of a UserResource
instance has to be an instance of OrganizationResource
.
While a GroupResource
instance is intended by the OP to be created directly via new GroupResource('groupId')
...
an OrganizationResource
instance gets accessed (or add-hoc created) via an GroupResource
instance's organization
method ...
and a UserResource
instance gets accessed (or add-hoc created) via an OrganizationResource
instance's user
method.
Because of the above described chaining behavior one needs to assure ...
that the last two (organization, user) mentioned sub resource types need to check each its correct parent resource type at instantiation time ...
and that the group and organization types need to keep track each of its related child resource types.
In addition, in order to not create a duplicate (by id
and parent
signature) resource sub type, one also needs to keep track of any ever created Resource
related reference.
The latter will be covered by a Map
based lookup and a helper function (getLookupKey
) which creates a unique lookup-key from every passed resource type.
Thus, whenever a group type is going to be created, a key-specific lookup takes place. In case an object of same id
value exists, it gets returned instead of a new instance. If there wasn't yet such an instance, it will be constructed, stored and returned.
Similarly, whenever either an organization or a user type are accessed, each through its related parent's method, a lookup from within the parent types preserved/private scope of related child types takes place. If there wasn't yet such an instance, it will be constructed, stored and returned as well.
// 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; }