Charlie Fish
Charlie Fish

Reputation: 20496

How to make class instance constructable in TypeScript?

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

Answers (2)

jcalz
jcalz

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!

Playground link to code

Upvotes: 2

VRoxa
VRoxa

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

Related Questions