Create a TypeScript mixin which both constrains and knows about what type it is mixed into

289 Views Asked by At

I'm trying to share some model objects between the client and server code (all in TypeScript). I would like to create the "shared" model so that it doesn't know anything about where it's being used, and then provide a mixin to add the server functionality (e.g., fetch from a database) on the server side, and a different mixin to provide the client functionality (e.g., fetch from a RESTful API) on the client.

Here's a simplified version of what I have so far (here's a playground link, too):

// generic declaration of a constructor type to make things easier later on
type Constructor<T> = new (...args: any[]) => T;

// this is the base model class which can be used by client or server code
class Model extends Object {
    public id: number = 1;
}

// this is a specific model subclass, also usable on client or server
class Widget extends Model {
    public length: number = 10;
}

// this class is only used on the server, but needs to know the type of model being acted upon
class ServerHelper<T> {
    public async insert(model: T): Promise<T> { /* do the insert */ return Promise.resolve(model); }
}

// this is the public interface for the server-side mixin
interface ServerModel<M extends Model> {
    helper: ServerHelper<M>;
}

// this is the server-side mixin which 
function ServerModel<B extends Constructor<Model>>(Base: B): B & Constructor<ServerModel<InstanceType<B>>> {
    type M = InstanceType<B>;

    const result = class BaseWithServerModel extends Base {
        public helper: ServerHelper<M> = new ServerHelper<M>();

        public async insert(): Promise<this> {
            return await this.helper.insert(this);
        }
    };

    return result;
}

class SpecialWidget extends ServerModel(Widget) {
    // this class needs this.helper to be a `Helper<Widget>`
}

I've been trying to adapt the constrained mixin example, but I can't find any formulation which gives me access to the type being mixed into (i.e., Widget in my example) so that it can be passed along to other generic types.

Instead, I get a bunch of error like this one on the return result; line:

'BaseWithServerModel' is assignable to the constraint of type 'M', but 'M' could be instantiated with a different subtype of constraint 'Model'.

I've spent hours digging around on the web and tinkering with various incantations on my own, but I've got nothing. Any suggestions on how I should declare my mixin so that I get access to M?

1

There are 1 best solutions below

0
On

The compiler cannot reason very well about the behavior of types that depend on an unspecified generic like B inside the body of ServerModel. Especially when the type is a conditional type like the InstanceType<T> utility type. So unfortunately code like the following just results in an error:

function foo<T extends new () => object>(ctor: T) {
    const oops: InstanceType<T> = new ctor(); // error
}

The knows that new ctor() is an object, but it doesn't realize that it must be an InstanceType<T>. See microsoft/TypeScript#37705 for a related issue.

If you want this to compile you will need to use something like a type assertion to tell the compiler that you know it's correct even though it doesn't:

function foo2<T extends new () => object>(ctor: T) {
    const oops = new ctor() as InstanceType<T>; // okay
}

So you'll need something like this in your code:

this.helper.insert(this as M)

A similar limitation is present with the polymorphic this type. Inside the body of a class, the this type is essentially a generic type parameter constrained to the type of the current class. So the compiler will not always be sure whether some value is assignable to this.

In your case,

    public async insert(): Promise<this> {
        return await this.helper.insert(this as M);
    }

the compiler is technically correct to complain. The return type of insert() should be a Promise<this> where this is the type of whatever subclass of BaseWithServerModel is being used. But this.helper.insert() returns only Promise<M>. And is is possible that this will be a proper subclass of M.

If you don't care about such a possibility because it's unlikely, you can use another type assertion:

    public async insert(): Promise<this> {
        return await this.helper.insert(this as M) as this;
    }

This compiles with no errors and lets you move on with your life. Maybe there are some better and more type safe solutions, but it might not be worth it.

In your comment you mentioned

    public async insert2(): Promise<this> {
        await this.helper.insert(this as M);
        return this;
    }

which may work as well.

Playground link to code