How to infer class declaration in dynamic context in typescript

I'm building a library and having a problem inferring type dynamically.

This is library side class and useModel returns Model instance

class Database {
  ...
  public useModel(target: Function) {
    const tableName = getClassMetadata(target).name;
    const table = this.config.tables.find(({ name }) => name === tableName);

    // Problem is here - Not infering
    type RType = typeof target extends (...args: any[]) => infer RT ? RT : Function;
    // Model has selectAll() which returns RType[]
    return new Model<RType>(this.connection, table);
  }
}

As a library consumer, usage is shown below.

I have custom decorators to add metadata values.

@Table({ name: 'Users', timestamps: true })
class Users {
  @PrimaryKey({autoIncrement: true, unique: true,})
  id: number;

  @Indexed({ unique: true })
  @Default('John')
  username: string;

  @Default(0)
  @Indexed({ unique: false })
  age: number;
}

const database = new Database({
  version: 1,
  name: 'MyDatabase',
  tables: [Users],
});

// I'd like Users class to be infered in useModel
const usersModel = database.useModel(Users);
usersModel.selectAll().then((users) => {
  users?.forEach((user) => {
    // user is not infered here :( 
    console.log(user);
  });
});

If I use generic in useModel I know it will work but I don't want to use generic value as const usersModel = database.useModel<typeof Users>(Users);

My goal is that Users class to be inferred automatically from useModel

This is desired usage const usersModel = database.useModel(Users);

Any suggestions?


Solution 1:

You don't need a conditional type for this, a simple generic type parameter is all you need. Consider:

class Database {

  useModel<T>(target: new () => T) {
    const tableName = getClassMetadata(target).name;
    const table = this.config.tables.find(({ name }) => name === tableName);
    
    return new Model<T>(this.connection, table);
  }
}

The reason that the original version did not work is because conditional types must be determined at compile time and cannot depend on a particular path of execution.

Regarding call site type inference, you wrote:

If I use generic in useModel I know it will work but I don't want to use generic value as const usersModel = database.useModel<typeof Users>(Users);

An API that requires type arguments to be passed explicitly is not merely inconvenient but much more importantly indicative of a flawed design and highly are prone.

const usersModel = database.useModel(Users);

Is fully supported by the above code and should be preferred for both readability and for correctness. In fact, explicitly passing type arguments at call sites is an awful practice. It's not merely an anti pattern but almost always indicates a serious design flaw. A generic function makes no sense unless all type parameters are inferable from the type of a value parameter. In this case, that value is target. Specifically, T is the type of the value constructed by newing target.

Further Remarks

The implementation above can be greatly simplified and guard against misuse, given that config.tables is an array of constructors.

Consider:

class Database {
  useModel<T>(target: new () => T) {
    if (!this.config.tables.includes(target)) {
      throw Error("Unconfigured Table");
    }

    return new Model(this.connection, target);
  }
}