0

In TypeScript, I have a class that stores a collection of objects.

class Collection<T extends Model> extends Array<T> {
  options: CollectionOptions;

  constructor(options: CollectionOptions) {
    super();

    this.options = options;
  }

  add(model: T): T {
    this.push(model);
    return model;
  }
}

I also have a mechanism to create new collections:

function createCollection<T extends Model>(
  _ModelClass: T, options: CollectionOptions
): Collection<T> {
  const collection: Collection<T> = new Collection(options);

  return collection;
}

I have some very reusable, generic options and a very basic model class:

type CollectionOptions = {
  key: string
}

class Model {
  [key: string]: any;
}

Now when I create a concrete class to use this with:

class User extends Model {
  id: number = 0
}

If I instantiate my collection like so:

const model = createCollection(new User(), { key: 'id' });

Then this is allowable:

model.add(new User());

But if I send the User class instead of an instance:

const model = createCollection(User, { key: 'id' });

Then I get this error when I try to do model.add(new User()):

Argument of type 'User' is not assignable to parameter of type 'typeof User'. Property 'prototype' is missing in type 'User' but required in type 'typeof User'.ts(2345)

Is there a way that I can modify my createCollection function to make it clear that its return type is a collection of instances, not classes?

Martyn Chamberlin
  • 1,277
  • 14
  • 17

1 Answers1

1

The _ModelClass argument to createCollection is completely unused inside the JavaScript, right? It looks as though its only purpose is to give the TypeScript compiler an inference site for the generic parameter T. If so, maybe the "right" solution is to do away with that argument entirely:

function createCollection<T extends Model>(
    options: CollectionOptions
): Collection<T> {
    const collection: Collection<T> = new Collection(options);
    return collection;
}

and either have callers manually specify the type of the T parameter as, say, User:

const model = createCollection<User>({ key: 'id' }); // manually specified 
model.add(new User()); // okay

or have them assign the return value to an manually annotated variable of type, say, Collection<User> and have the compiler infer T as User contextually from the desired return type:

const model2: Collection<User> = createCollection({ key: 'id' });
model2.add(new User()); // okay

If you want to preserve the dummy _ModelClass argument and your intent is to pass in a class constructor instead of a class instance, then createCollection() has the wrong signature. Class declarations like your User example introduce both a named interface type corresponding to the type of class instances, and a named value referring to the class constructor. And these are different, despite having the same name. The constructor value named User is not itself of type User.

The type named User is sort of like {id: number; [key: string]: any;}; an object with a numeric id property and a bunch of other possible properties, while the type of the value User (that is, typeof User,) is sort of like new () => User; a newable object that produces a value of type User when called with new.

If you care to read more about the distinction between types and values and how the same name can refer to a type and a value depending on context, you could look at another lengthy monologue of mine on this subject.

In any case, if your intent is to accept constructors and not instances, you should annotate the type of the _ModelClass parameter not as T, but as something like new (...args: any) => T which means "a newable constructor that takes some number of arguments we don't care about and produces a value of type T":

function createCollection<T extends Model>(
    _ModelClass: new (...args: any) => T,
    options: CollectionOptions
): Collection<T> {
    const collection: Collection<T> = new Collection(options);
    return collection;
}

Then your function will accept the User constructor:

const model = createCollection(User, { key: 'id' });
model.add(new User()); // okay

But complain about a User instance:

const modelBad = createCollection(new User(), { key: 'id' }); // error!
// Type 'User' is not assignable to type 'new (...args: any) => Model`

If for some reason you want the compiler to accept either an instance or a constructor as _ModelClass, you can annotate that parameter as a union of the instance and the constructor types:

function createCollection<T extends Model>(
    _ModelClass: (new (...args: any) => T) | T,
    options: CollectionOptions
): Collection<T> {
    const collection: Collection<T> = new Collection(options);
    return collection;
}

And then it will work both ways:

const modelCtor = createCollection(User, { key: 'id' });
modelCtor.add(new User()); // okay

const modelInstance = createCollection(new User(), { key: 'id' }); // error!
modelInstance.add(new User()); // okay

Okay, hope that helps; good luck!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360