If I were to refine this method, the revised version might resemble something as follows:
// Unfortunately, static abstract methods are not supported in TS.
// In an ideal scenario, we would prefer compile-time enforcement.
export default class BaseModel {
static get singularName(): string {
throw new Error('Must supply name in derived class!');
}
static get pluralName(): string {
throw new Error('Must supply name in derived class!');
}
// Commonly used names for these static object construction methods include
// "of" and "from".
static async create(id: string): Promise<any> {
throw new Error('Must override in derived class!');
}
}
export type SynapseType = {
id?: string,
text: string,
created_at?: string,
updated_at?: string
}
export class Synapse extends BaseModel {
id?: string;
text!: string;
created_at?: string;
updated_at?: string;
static singularName: 'synapse'
static pluralName: 'synapses'
constructor(data: SynapseType) {
super();
Object.assign(this, data);
}
static async create(id: string): Promise<Synapse> {
const data = await fetcher.get(`${this.pluralName}/${id}`);
return new Synapse(data);
}
}
This version brings about a few improvements. Duplication is minimized, and the redundant base class constructor has been eliminated. If you attempt to construct an object without overriding the static create
method or forget to set the names, you will quickly receive an error. It would be preferable if these checks could happen at compile time. Additionally, the return types of the async static creation methods are now more strongly typed.
To enforce calling constructors via static methods exclusively when incorporating real logic into those static methods, you can use the type system as follows:
class Foo extends BaseModel {
static singularName: 'foo'
static pluralName: 'foos'
private static unique = (() => { enum Bar { _ = '' }; return Bar })()
constructor(_lock: typeof Foo.unique, public readonly id: string) {
super();
}
static async create(id: string): Promise<Foo> {
return new Foo(Foo.unique, id);
}
}
// Attempting to call new Foo will result in an error
const foo = new Foo(Foo.unique, 'abc123'); // error
const bar = Foo.create('abc123'); // no issues
Playground
Edit
You inquired about my preferred method in the comments. Here's how I would approach it:
interface BaseModel {
new (...args: any[]): any
a: string
}
const factory = <C extends BaseModel>(Clazz: C) => {
return async (...args: ConstructorParameters<C>): Promise<InstanceType<C>> => {
const resp = await fetch('some-url');
const data = await resp.json();
return new Clazz(...args);
}
}
class Foo {}
class Bar { static a = 'a' }
const makeFoo = factory(Foo); // fails, missing 'a'
const makeBar = factory(Bar);
By using this approach, the static members function as compile-time checks: any mismatches with the BaseModel interface will be immediately flagged by your IDE. There's no need for workarounds like enforcing constructor locks using enums as seen earlier -- simply refrain from exporting concrete classes and only export the makeWhatever
factory functions.
Playground
Similar to Ruby, JavaScript embodies multiple paradigms: if the object-oriented methodology feels cumbersome, integrating functional code can certainly enhance the overall experience.