0

I am getting a TypeScript 2417 error on the Table class implementing the following code:

export abstract class BaseTable {
  protected constructor() {}

  static Builder = class<T extends BaseTable> {
    protected constructor() {}

    build(): T {
      return this.buildTable();
    }

    protected buildTable(): T {
      throw new Error("Must be implemented in subclasses");
    }
  };
}

export class Table extends BaseTable {
  private constructor() {
    super();
  }

  static Builder = class extends BaseTable.Builder<Table> {
    protected constructor() {
      super();
    }

    static create() {
      return new Table.Builder();
    }

    protected buildTable(): Table {
      return new Table();
    }
  };
}

This produces the error

Class static side 'typeof Table' incorrectly extends base class static side 'typeof BaseTable'.
  The types returned by '(new Builder()).build()' are incompatible between these types.
    Type 'Table' is not assignable to type 'T'.
      'Table' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'BaseTable'.(2417)

My goal is to have private or protected constructors and to be able to call the code in 2 steps

const builder = Table.Builder.create();
const table = builder.build();

I thought this might be caused by the Builder class being static so I have tried this:

export abstract class BaseTable {
  constructor() {}
}

export namespace BaseTable {
  export abstract class Builder<T extends BaseTable> {
    protected constructor() {}

    build(): T {
      return this.buildTable();
    }

    protected abstract buildTable(): T;
  }
}

export class Table extends BaseTable {
  constructor() {
    super();
  }
}

export namespace Table {
  export class Builder extends BaseTable.Builder<Table> {
    protected constructor() {
      super();
    }

    static create() {
      return new Table.Builder();
    }

    protected buildTable(): Table {
      return new Table();
    }
  }
}

This code produces the same error and defeats the purpose of having private constructors.

Can someone explain this error to me and how to implement this code.

Andrew Alderson
  • 968
  • 5
  • 16

1 Answers1

1

The problem is that BaseTable.Builder is declared as a generic class that can act as any T extends BaseTable that the consumer wants. Whoever gets to call new BaseTable.Builder() (and it's not clear to me who that could be since the constructor is protected)) gets to specify T, like new BaseTable.Builder<{foo: string}>().

In order to extend BaseTable, a class's static side and instance side both need to be assignable to the static and instance sides of BaseTable, respectively. That is, there is both interface side and static side inheritance; see microsoft/TypeScript#4628 for some discussion on whether static inheritance is desirable. For the foreseeable future, though, that's the way it is.

So Table.Builder must be assignable to BaseTable.Builder. But it's not. Whereas Table.Builder can be used (by whom?) to create new things that build() any type T extends Basetable that the caller wants, BaseTable.Builder can only be used (again, by whom?) to create new things that build() a Table.

If Table properly extends BaseTable, and if we get rid of the privacy/protection modifiers so someone can actually demonstrate the typing issue (and ignore construct signatures), the issue is this:

const hmm = new BaseTable.Builder<{ foo: string }>().build().foo; // okay
const AlsoBaseTable: typeof BaseTable = Table;
const uhOh = new AlsoBaseTable.Builder<{ foo: string }>().build().foo;

You should be allowed to largely treat the Table class constructor as if it had the same static properties as BaseTable's class constructor, which would be a problem if your Table.Builder implementation is not generic the same way BaseTable's is.


So, stepping back, I think that you don't really want BaseTable.Builder to be generic, or at least not generic in the way that TypeScript's generics imply. TypeScript's generics are universal and not existential (see this Q/A for more info), so when you say class <T extends BaseTable>{...} you're saying this works for all specifications of T, and not just some specification of T.

Instead, what you might want to do is widen BaseTable.Builder so that it only claims to do things with BaseTable, and then let subclasses narrow as they see fit. This doesn't necessarily enforce all the constraints you want to see enforced, but as long as you implement the subclasses properly things should work out:

abstract class BaseTable {
    protected constructor() { }

    static Builder = class {
        protected constructor() { }

        build(): BaseTable {
            return this.buildTable();
        }

        protected buildTable(): BaseTable {
            throw new Error("Must be implemented in subclasses");
        }
    };
}


class Table extends BaseTable {
    private constructor() {
        super();
    }

    static Builder = class extends BaseTable.Builder {
        protected constructor() {
            super();
        }

        static create() {
            return new Table.Builder();
        }

        protected buildTable(): Table {
            return new Table();
        }
    };
}

Now there are no errors (other than possibly declaration warnings and other private/protected things that go bump in the night) because Table.Builder is a true subtype of BaseTable.Builder.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • I do know that I can remove the generics from this but that means I have to cast the Table instance to the correct type. I could also just write it in vanilla JavaScript. This code as is does compile and run and does exactly what I want and expect it to do. When I inspect the prototype chain it looks exactly how I expect it to look. In a perfect world TypeScript would let me suppress this error because it is not important for my implementation. The issue here is with TypeScript so I am trying to figure out if there is a way to write this using generics that makes TypeScript happy. – Andrew Alderson Dec 22 '20 at 01:39
  • "I have to cast the Table instance to the correct type". Really? Show me. – jcalz Dec 22 '20 at 01:41
  • If you just want to suppress an error, you can use [`// @ts-ignore`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#suppress-errors-in-ts-files-using--ts-ignore-comments) like [this](https://tsplay.dev/AwQ4GW). But the error is there for a reason, which is that `Table` as you wrote it does not properly extend `BaseTable`. If your `Table` is the right type, then `BaseTable` is wrong and needs to be altered. That's as close to "Can someone explain this error to me and how to implement this code" as I can get. If you get a better answer, let me know. Cheers! – jcalz Dec 22 '20 at 01:47
  • I didn't know about the `// @ts-ignore`. I do understand this issue a lot more from your answer but it isn't quite what I was looking for. You repeatedly say that the `Table` class doesn't properly extend `BaseTable` but you don't say how. I have managed to remove the error by adding a static create function to `BaseTable.Builder` even though it will never be called but it only works if I turn off `noImplicitAny` in the tsconfig because I haven't figure out how to type it yet. I wonder if this would have been an issue if I could make the `BaseTable.Builder` abstract and static – Andrew Alderson Dec 22 '20 at 05:00
  • "You repeatedely say that the `Table` class doesn't properly extend `BaseTable` but you don't say how." In the answer I explain and demonstrate exactly how `Table.Builder` is not assignable to `BaseTable.Builder`, as required for static inheritance: the former can only construct `Table`-`build`ers, while the latter can construct any `T extends BaseTable`-`build`ers the construct caller wants. The `uhOh` line demonstrates this. If that is not sufficient or if you don't understand it, could you tell me where the disconnect is so I can address it? – jcalz Dec 22 '20 at 14:27
  • [Here](https://tsplay.dev/lm00Gm) is the most detailed explanation I can give without knowing what the specific disconnect it. Please read that and see if it makes sense to you. – jcalz Dec 22 '20 at 14:50
  • Thanks. I have been trying to get a deeper understanding of the nuances of TypeScript and this helps although I haven't fully wrapped my head around it. I will revisit this in the future once I get a better grasp of these concepts. – Andrew Alderson Dec 22 '20 at 19:24