Skapa en TypeScript-mixin som både begränsar och vet vilken typ den blandas in i


Jag försöker att dela några modellobjekt mellan klient- och serverkod (allt i TypeScript). Jag skulle vilja skapa den "delade" modellen så att den inte vet något om var den används, och sedan tillhandahålla en mixin för att lägga till serverfunktionalitet (t.ex. hämta från en databas) på serversidan, och en annan mixin för att tillhandahålla klientfunktionalitet (t.ex. hämta från ett RESTful API) på klientsidan.

Här är en förenklad version av det jag har hittills (här är en länk till en lekplats också):

// 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>`
}

Jag har försökt anpassa exemplet med den begränsade mixinen, men jag kan inte hitta någon formulering som ger mig tillgång till typen som mixas in (dvs. Widget i mitt exempel) så att den kan skickas vidare till andra generiska typer.

Istället får jag en massa fel som detta på raden return result;:

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

Jag har tillbringat timmar med att gräva runt på webben och pillat med olika trolldomar på egen hand, men jag har ingenting. Några förslag på hur jag borde deklarera min mixin så att jag får åtkomst till M?

Svar #1

Kompilatorn kan inte resonera särskilt bra om beteendet hos typer som beror på en ospecificerad generisk som B inuti kroppen av ServerModel. Speciellt när typen är en villkorlig typ som hjälpmedelstypen InstanceType<T>. Så tyvärr resulterar kod som följande helt enkelt i ett fel:

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

Det vet att new ctor() är ett object, men det inser inte att det måste vara en InstanceType<T>. Se microsoft/TypeScript#37705 för ett relaterat problem.

Om du vill att detta ska kompilera behöver du använda något som en typförsäkran för att tala om för kompilatorn att du vet att det är korrekt även om det inte är det:

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

Så du kommer att behöva något som detta i din kod:

this.helper.insert(this as M)

En liknande begränsning finns med den polymorfa this-typen. Inuti en klass kropp är this-typen i princip en generisk typparameter begränsad till typen av den aktuella klassen. Så kompilatorn kommer inte alltid vara säker på om ett värde kan tilldelas this.

I ditt fall,

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

kompilatorn har tekniskt sett rätt att klaga. Returtypen för insert() borde vara en Promise<this> där this är typen av vilken subklass av BaseWithServerModel som används. Men this.helper.insert() returnerar endast Promise<M>. Och det är möjligt att this kommer att vara en riktig subklass av M.

Om du inte bryr dig om en sådan möjlighet eftersom den är osannolik, kan du använda en annan typavklaring:

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

Detta kompilerar utan fel och låter dig gå vidare med ditt liv. Kanske finns det några bättre och mer typesäkra lösningar, men det kanske inte är värt det.

I din kommentar nämnde du

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

vilket kan fungera lika bra.

Playground-länk till kod