typescripttypescript-generics

Problem with TypeScript generic base classes referring to each other


This is pretty much a minimal example to reproduce the problem. I have an Api base class that I want to be able to instantiate the ApiObject base class it's for. I also want the ApiObject to hold a reference to the Api object that created it.

class ApiObject<T extends Record<string, unknown>> {
  constructor(private obj: T, private api: Api<T, ApiObject<T>>) {}
  delete() {}
}
class Api<T extends Record<string, unknown>, O extends ApiObject<T>> {
  constructor(private creator: new (obj: T, api: Api<T, O>) => ApiObject<T>) {}
  get(obj: T) {
    return new this.creator(obj, this) as O;
  }
}

This is a test case that shows the problems:

type Test = { x: number };
class TestObject extends ApiObject<Test> {
  change() {}
}
class TestApi extends Api<Test, TestObject> {
  constructor() {
    super(TestObject);  // *1
  }
}

const api = new TestApi();
const obj = api.get({ x: 1 });
obj.change(); // *2

As it is there, I get an error at *1: "Property 'change' is missing in type 'ApiObject' but required in type 'TestObject'.ts(2345)".

If I remove the as O from return new this.creator(obj, this) as O; in Api.get, I instead get an error at *2: "Property 'change' does not exist on type 'ApiObject'.ts(2339)"

I use the creator variable to allow the Api class to create a new ApiObject from data it gets. I'd like a reference to the Api object so I could make calls against it within ApiObject


Solution

  • You can't merely say that ApiObject<T> has an api of type Api<T, ApiObject<T>>, because there's nothing to say that any particular subclass of ApiObject<T>'s api will produce the same subclass of ApiObject<T>. Right now Api knows about ApiObject's subtyping, but ApiObject doesn't know anything about Api's subtyping. They both need to know about each other for it to work.

    If subclasses of both Api and ApiObject need to know specifically about each other's type details to work, then you'll need to use recursive constraints (a.k.a. F-bounded quantification). You already have an ApiObject constraint on Api's O parameter, but you don't have anything in ApiObject's constraints that point to Api or to itself. In general you might have to add both an A (for Api) and an O to both classes, but it looks like you can get away with having just O:

    class ApiObject<T extends Record<string, unknown>, O extends ApiObject<T, O>> {
        constructor(private obj: T, private api: Api<T, O>) { }
        delete() { }
    }
    
    class Api<T extends Record<string, unknown>, O extends ApiObject<T, O>> {
        constructor(private creator: new (obj: T, api: Api<T, O>) => O) { }
        get(obj: T) {
            return new this.creator(obj, this);
        }
    }
    

    See how Api<T, O>'s creator produces an O, and ApiObject<T, O> takes an Api<T, O>, so the type information you care about is reserved. Now your subclasses need explicit references to themselves also:

    type Test = { x: number };
    
    class TestObject extends ApiObject<Test, TestObject> {
        change() { }
    }
    
    class TestApi extends Api<Test, TestObject> {
        constructor() {
            super(TestObject);  // okay
        }
    }
    
    const api = new TestApi();
    const obj = api.get({ x: 1 });
    obj.change(); // okay
    

    And this works. Note that sometimes you can avoid such explicit recursive generic constraints by using the polymorphic this type, which is effectively an implicit generic type parameter that's constrained by the class. That is, the type of this inside ApiObject<T> would be used in place of O. But polymorphic this is only allowed inside instances of classes, and you'd need the constructor (which is static) for it to work here. Without something like microsoft/TypeScript#5863, it's not currently possible in TypeScript. So you need the explicit recursive generic. Annoying, but it works.

    Playground link to code