'How do I create a generic factory method that access static members of the class type it's creating
I'm using Typescript to write REST API endpoints as Functions over Firebase, and the methods all follow a similar pattern: check for a request.body, pull the appropriate data our of that body data, put it into a strongly-typed object, the use that object to push the data to the database via some data-access code. After writing the same basic data-extraction logic several times for dealing with the request.body, I figured there must be a way to abstract this work. I have three requirements for this: (1) the method should work to pull data from request.body for any of my data models. (2) The data models should be fully self-descriptive, so that they not only describe the properties the data should have, but they can relate when a certain set of properties is required. (3) The method should be able to tell from the data models which properties are required and do some validation on the data passed via the request.body.
As an example of #2, and the models being self-descriptive: consider, e.g., that when I'm creating a new data record, I don't require an ID, since if it's not there I can create it in the function and pass it back. The "name" property, on the other hand is required in this case. By contrast, an update method requires a record ID (so it knows which record to update), but doesn't require the "name", unless that's what is actually being modified.
My approach has been to use (1) a static factory method on a separate class that takes the class type for the data model that needs to be created; the intended operation (i.e. create, read, update, or delete); and the request body. (2) A set of data model classes that basically just describe the data and include a little validation logic where needed, but also include a (static) list of field names and associated requirement values (stored as four bits, where each position represents one of the four CRUD operations.) (3) A common interface, so that the static factory method knows how to deal with the different data objects to get those field names and usage flags.
Here is my static factory method:
static create<T extends typeof DataObjectBase>(cls: { new(...args: any[]): T; }, intendedOperation: number, requestBody: any) : T {
let dataObject : T = null;
const sourceData = {};
const objFields = cls.fieldNames;
const flagCollection = cls.requiredUseFlags();
const requiredFields = flagCollection.getFieldsForOperation(intendedOperation);
if (requestBody) {
// parse the request body
// first get all values that are available and match object field names
const allFields = Object.values(objFields); // gets all properties as key/value pairs for easier iteration
// iterate through the allFields array
for (const f in allFields) {
if (requestBody.hasOwnProperty(f)) {
// prop found; add the field to 'sourceData' and copy the value from requestBody
sourceData[f] = requestBody[f];
} else if (requiredFields.indexOf(f)>-1) {
// field is required but not available; throw error
throw new InvalidArgumentError(`${cls}.${f} is a required field, but no value found for it in request.body.`, requestBody);
}
}
dataObject = (<any>Object).assign(dataObject, sourceData);
} else {
throw new ArgumentNullError('"requestBody" argument cannot be null.', requestBody);
}
return new cls();
}
Here's an example of a data model class:
export class Address extends DataObjectBase {
constructor(
public id : string,
public street1 : string,
public street2 : string = "",
public city : string,
public state : string,
public zip : string) {
// call base constructor
super();
}
static fieldNames = {
ID = "id",
STREET1 = "street1",
STREET2 = "street2",
// you get the idea...
}
static requiredUseFlags() {
ID = READ | UPDATE | DELETE,
STREET1 = 0,
// again, you get the idea...
// CREATE, READ, UPDATE, DELETE are all bit-flags set elsewhere
}
}
I want to be able to call the above create
method like so:
const address = create<Address>(Address, CREATE, request.body);
Originally, I tried a signature like this:
static create<T extends typeof DataObjectBase>(cls: T, intendedOperation: number, requestBody: any) : T
When I did this, however, I got an error that "Address is a type but is being used as a value." Once I changed it to what I have above, I stopped getting that error and started getting
Property 'fieldNames' does not exist on type 'new (...args: any[]) => T'
Note: I've also tried the trick of using two interfaces to describe (in the first) the instance methods and (in the second) the static methods, and then have the static interface extend the instance interface, and the base class implement the static interface, etc., such as is described here, here, and here. This hasn't quite gotten me there, either.
I'm certainly willing to concede that I might very well have over-engineered this all, and I'm happy to entertain other, simpler suggestions. But I figure there has to be a way to accomplish what I want and avoid having to write the same basic request-body-parsing code over and over.
Solution 1:[1]
You can use this
inside a static method to refer to the current class (enabling you do write new this()
to create an instance of the class).
With regard to typing this in such a way as to both be able to construct objects and have access to statics, the simplest solution is to have the constructor signature as you defined it and add the statics back in by using an intersection with Pick<typeof DataObjectBase, keyof typeof DataObjectBase>
. This will keep the static members but remove any constructor signatures of the base class.
Also T
should extend DataObjectBase
(the instance type) not typeof DataObjectBase
(the type of the class)
type FieldsForOperation = {
getFieldsForOperation(intendedOperation: number): string[]
}
class DataObjectBase {
static fieldNames: Record<string, string>
static requiredUseFlags():FieldsForOperation { return null!; }
static create<T extends DataObjectBase>(
this: (new (...a: any[]) => T) &
Pick<typeof DataObjectBase, keyof typeof DataObjectBase>,
intendedOperation: number,
requestBody: any
): T {
let dataObject : T = null;
const sourceData = {};
const objFields = this.fieldNames;
const flagCollection = this.requiredUseFlags();
// rest of code
return new this();
}
}
export class Address extends DataObjectBase {
constructor(
public id : string,
public street1 : string,
public street2 : string = "",
public city : string,
public state : string,
public zip : string) {
// call base constructor
super();
}
static fieldNames = {
"": ""
}
static requiredUseFlags(): FieldsForOperation {
return null!;
}
}
Address.create(0, {})
Note: Just fixing the TS not going to wager an opinion on the over-engineering part ?
Solution 2:[2]
Can this be also documented with jsdoc?
/**
* @template {DataObjectBase} T
* @this {new (...args: any[]) => T}
* @returns {Promise<T>}
*/
static create (
intendedOperation: number,
requestBody: any
) {...}
The method signature of create is understood by vscode, but when calling, DataObjectBase
and Address
don't understand the polymophic nature of this
.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | rpatel |
Solution 2 | 1qnew |