sudoremo
sudoremo

Reputation: 2402

How to create a subclass with more specific generics

In the following code, I've outlined two problems (see comments):

  1. I need a static method to return an instance of the current class. When it is called on a subclass (without being overwritten), it should return an instance of the subclass.
  2. I need a generic to be more specific in a subclass (Base should take BaseOptions while Sub should take SubOptions which are a superset of BaseOptions).

Solutions with extends BaseOptions | SubOptions don't work for me, as I cannot enumerate all of the possible, more specific types of options to keep the code generic.

Am I approaching this the wrong way altogether or is there a solution to my problems?

interface BaseOptions {
  a: string
}

interface SubOptions extends BaseOptions {
  a: string
  b: string
}

class Base<T extends BaseOptions> {
  options: T

  constructor(options: T) {
    this.options = options;
  }

  // Problem 1: When TypeScript is concerned, this method always returns an
  // instance of "Base", even when calling 'create' on a subclass (that does not
  // override 'create'). How can this be fixed?
  static create<T extends BaseOptions>(options: T): Base<T> {
    return new this(options);
  }
}

// Problem 2: Class 'Sub' requires more specific options than class 'Base', but
// TypeScript does not allow me to validate it:
//   2417: Class static side 'typeof Sub'
//         incorrectly extends base class static
//         side 'typeof Base'.
class Sub<T extends SubOptions> extends Base<T> {
  static create<T extends SubOptions>(options: T): Sub<T> {
    return new this(options);
  }
}

Upvotes: 4

Views: 539

Answers (2)

Friedrich
Friedrich

Reputation: 2290

Short Answer:

Remove all the unnecessary stuff and keep it real. This works:

type BaseOptions = {
  a: string;
};

type SubOptions = {
  a: string;
  b: string;
} & BaseOptions;

class Base<BO extends BaseOptions> {
  options: BO;

  constructor(options: BO) {
    this.options = options;
  }

  static create<BO extends BaseOptions>(options: BO): Base<BO> {
    return new this(options);
  }
}

class Sub<SO extends SubOptions> extends Base<SO> {}

You can force TypeScript to use the correct type like this:

const sub = Sub.create({ a: 'a', b: 'b' }) as Sub<...>;

Long Answer:

This might be a TypeScript error - that's it; I really said that. Here is why I think so:

Using the code from the short answer we can create an object called sub like this:

const sub = Sub.create({ a: 'a', b: 'b' });

TypeScript will choose to type infer Base<{ a: string, b: string } - I am guessing you want to have the more explicit definition and infer the type Sub<...>.

To see what the underlying JS-Interpreter does, let's see what happens in node when using plain JavaScript.

class Base {
  constructor(options) {
    this.options = options
  }

  static create(options) {
    return new this(options)
  }
}

class Sub extends Base {}

Sub.create({ a: 'a', b: 'b' })

This outputs

> Sub { options: { a: 'a', b: 'b' } }

There you have it. That's the object with the correct class assigned; perfectly safe to overwrite the TypeScript inferred type.

Upvotes: 1

dbonev
dbonev

Reputation: 402

interface IGenericMap<T> {
  [x: string]: T;
}
  
class Base<T extends IGenericMap<string>> {
  options: T

  constructor(options: T) {
    this.options = options;
  }

  static create<T extends IGenericMap<string>>(options: T): Base<T> {
    return new this(options);
  }
}

class Sub<T extends IGenericMap<string>> extends Base<T> {
  constructor(options: T) {
    super(options);
  }
}

console.log(Base.create({ a: 'one' }));
console.log(Sub.create({ a: 'one', b: 'two' }));

Now you can use the inheritance properly and the create methods returns the appropirate class.

If use the object mapping instead of BaseOptions and SubOptions inside the IGenericMap you are going to have any key with a generic value by choise. In the example above string and you can change it with any other type: number, Object, any, string | number ...

UPDATE: an example with types:

interface BaseOptions {
  a: string
}

interface SubOptions extends BaseOptions {
  a: string
  b: string
}
  
class Base<T extends BaseOptions> {
  options: T

  constructor(options: T) {
    this.options = options;
  }

  static create<T extends BaseOptions>(options: T): Base<T> {
    return new this(options);
  }
}

class Sub<T extends SubOptions> extends Base<T> {
  constructor(options: T) {
    super(options);
  }
}

console.log(Base.create({ a: 'one' }));
console.log(Sub.create({ a: 'one', b: 'two', c: '' }));

Upvotes: 0

Related Questions