Reputation: 20496
I'm trying to convert this package to TypeScript without any breaking changes. I have the following code in TypeScript.
// DocumentCarrier.ts
/* export */ class DocumentCarrier {
internalObject: {};
model: Model;
save: (this: DocumentCarrier) => void;
constructor(model: Model, object: {}) {
this.internalObject = object;
this.model = model;
}
}
DocumentCarrier.prototype.save = function(this: DocumentCarrier): void {
console.log(`Saved document ${JSON.stringify(this.model)} to ${this.model.myName}`);
};
// Model.ts
// import {DocumentCarrier} from "./DocumentCarrier.ts";
/* export */class Model {
myName: string;
Document: typeof DocumentCarrier;
get: (id: number) => void;
constructor(name: string) {
this.myName = name;
const self: Model = this;
class Document extends DocumentCarrier {
static Model: Model;
constructor(object: {}) {
super(self, object);
}
}
Document.Model = self;
Object.keys(Object.getPrototypeOf(this)).forEach((key) => {
Document[key] = this[key].bind(this);
});
this.Document = Document;
return this.Document as any;
}
}
Model.prototype.get = function(id: number): void {
console.log(`Retrieving item with id = ${id}`);
}
// Usage
// index.ts
// import {Model} from "./Model.ts";
const User = new Model("User");
const user = new User({"id": 5, "name": "Bob"});
user.save(); // "Saved document {"id": 5, "name": "Bob"} to User"
console.log(User.Model.myName); // "User"
// console.log(User.myName); // "User" // This option would be even better, but isn't supported in the existing code
User.get(5); // "Retrieving item with id = 5"
In the Usage
section (very bottom of the code example above) I'm getting multiple errors in TypeScript. But running that code in a JavaScript file, works perfectly. So I know it's working and the code is accurate.
I think the biggest problem of what I'm trying to do is return this.Document as any
. TypeScript is interpreting that as casting this.Document
to a Model instance, when in reality it's not.
My question is this. In TypeScript how can I set it up where you can run new MyClassInstance()
and have it return an instance of a different class? That has a bidirectional reference from MyClassInstance
and the different class. In short, how do I get the following code working?
It's important that any solution works with the Usage
section, and no modifications are made to that section. Except for the User.Model.myName
vs User.myName
section, which would be preferred as User.myName
, but in the existing version functions as User.Model.myName
.
For easy use, I also created a TypeScript Playground.
Upvotes: 1
Views: 4190
Reputation: 327624
I'm going to interpret this question strictly as "how can I give typings to the existing code so that the compiler understands the code in the Usage
section?" That is, the answer should not touch the emitted JavaScript, but instead should only alter type definitions, annotations, and assertions.
Aside: the more general question "how should I implement a class whose instances are themselves class constructors" is one I won't attempt to address, since from my research the best answer here is "don't try to do that" since it plays poorly with the prototypical inheritance model in JS. I'd instead lean strongly toward having a non-constructible class instance hold a property which is the constructor of the new class. Something like this Playground code. You'd be a lot happier in the long run, I expect.
Back to the typings: the main problem here is that TypeScript has no way to specify that a class constructor returns a type other than the class being defined. This is either intentional (see microsoft/TypeScript#11588 or a missing feature (see microsoft/TypeScript#27594) but in any case it's not part of the language.
What we can do here is to use declaration merging. When you write class Model {}
you introduce both a class constructor object named Model
and an interface type named Model
. That interface can be merged into, adding methods and properties that the compiler doesn't already know about. In your case you could do this:
interface Model {
new(object: {}): DocumentCarrier;
Model: Model;
}
This lets the compiler know that Model
instances, in addition to having the properties/methods declared in the class, also has a Model
property whose type is Model
, and, importantly, a constructor signature. That's enough to get the following code to compile without error:
const User = new Model("User");
const user = new User({ "id": 5, "name": "Bob" });
user.save(); // "Saved document {"id": 5, "name": "Bob"} to User"
console.log(User.Model.myName); // "User"
User.get(5); // "Retrieving item with id = 5"
The compiler does think that User.myName
exists, which it doesn't at runtime, but that's already a problem with the existing code so I'm not touching that here. It's possible to change the typings further so that the compiler knows that User.Model.myName
exists and that User.myName
does not exist, but that becomes quite complicated as it requires you to split Model
's interface into multiple types that you carefully assign to the right values. So for now I'm ignoring it.
The only other change I'd make here would be to give different typings to the implementation of Model
, like this:
class Model {
myName: string;
Document: Model;
get!: (id: number) => void;
constructor(name: string) {
this.myName = name;
const self: Model = this;
class Document extends DocumentCarrier {
static Model: Model;
constructor(object: {}) {
super(self, object);
}
}
Document.Model = self;
(Object.keys(Object.getPrototypeOf(this)) as
Array<keyof typeof DocumentCarrier>).forEach((key) => {
Document[key] = this[key].bind(this);
});
this.Document = Document as Model;
return this.Document;
}
}
The only thing the compiler won't be able to verify in the above is that the Document
class is a valid Model
, so we use the assertion Document as Model
. Other than that I just put a few assertions (get
is definitely assigned, and Object.keys()
will return an array of keys of the DocumentCarrier
constructor) so that you don't need to turn off the --strict
compiler flag.
Okay, hope that helps. Good luck!
Upvotes: 2
Reputation: 1051
After roaming a bit, I got something.
Typescript complains about your solution because, even if you are returning a class Document
internally in Model
constructor, the compiler expects a Model
instance, which is not constructable.
So, we need to make Model
constructable. In fact, the same as making a function which returns instances of something.
First, let's declare your preovious DocumentCarrier
class. Now, DocumentCarrier
will have two properties, model
and name
(this was your previously keyed myName
from Model
class).
class DocumentCarrier {
name: string = ``;
constructor(public model: {}) { }
save = () => console.log(`Saved document ${JSON.stringify(this.model)} to ${this.name}`)
}
After that, we need that function declaration that returns an instance model of type DocumentCarrier
.
const Model = (name: string) => {
return class extends DocumentCarrier {
name: string = name;
constructor(model: any) {
super(model);
}
}
}
The function takes a string
parameter and returns a constructor of type DocumentCarrier
which takes an any
object on its constructor and passes to the DocumentCarrier
constructor.
We can call Model
like this.
const User = Model('User');
const user = new User({id: 5, name: 'Bob'});
user.save(); // Saved document {"id":5,"name":"Bob"} to User
The call to Model
is the only change made. Now Model
call does not need a new
keyword.
On the other hand, name
in DocumentCarrier
can be accessed from the last instance constructed.
In addition, this solution could be a bit more generic by defining generic types.
type Constructor<T> = new (...args: any[]) => T;
The type Constructor
constraints a constructor function of any type T
.
Now, we need to rewrite the Model
function:
const Model = <T extends Constructor<{}>>(Type: T, name: string) => {
return class extends Type {
name: string = name;
constructor(...args: any[]) {
super(...args);
}
}
}
Model
now expects the same string as before but an additional parameter which is the type we want to instantiate, so
const User = Model(DocumentCarrier, 'User');
const user = new User({id: 5, name: 'Bob'});
user.save(); // Saved document {"id":5,"name":"Bob"} to User
Even more, since we are fulfilling a property name
that belongs to the instance we are creating inside the Model
function, we should constraint the input type to expect that name
property.
type Constructor<T> = new (...args: any[]) => T;
interface DynamicallyConstructed {
name: string;
}
class DocumentCarrier implements DynamicallyConstructed {
name: string = ``;
constructor(public model: {}) { }
save = () => console.log(`Saved document ${JSON.stringify(this.model)} to ${this.name}`)
}
const Model = <T extends Constructor<DynamicallyConstructed>>(Type: T, name: string) => {
return class extends Type {
name: string = name;
constructor(...args: any[]) {
super(...args);
}
}
}
const User = Model(DocumentCarrier, 'User');
const user = new User({id: 5, name: 'Bob'});
user.save();
Hope this helps a bit.
Please comment any issue.
Upvotes: 0