Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Constructor of a generic class cannot be assigned to a generic constructor-function type because of premature specialization #31840

Closed
bajtos opened this issue Jun 10, 2019 · 4 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@bajtos
Copy link

bajtos commented Jun 10, 2019

TypeScript Version: 3.6.0-dev.20190608

Search Terms:

Code

export class Entity {
  // members are not important
}

/**
 * A generic interface.
 *
 * NOTE:
 * The first generic parameter is required,
 * the second one is optional.
 */
export interface EntityCrudRepository<
  T extends Entity,
  Relations extends object = {}
> {
  findFirst(): Promise<T & Relations>;
}

/**
 * A generic class implementing the generic interface.
 */
export class DefaultCrudRepository<
  T extends Entity,
  Relations extends object = {}
> implements EntityCrudRepository<T, Relations> {
  constructor(
    private entityClass: typeof Entity & {prototype: T},
    private dataSource: object,
  ) {}

  async findFirst(): Promise<T & Relations> {
    // dummy implementation, not important
    return {} as (T & Relations);
  }
}

/**
 * A type representing any generic class that's implementing 
 * our generic interface and providing a two-arg constructor.
 * E.g. `DefaultCrudRepository`, but also any other class
 * matching the required API contracts.
 */
export type CrudRepositoryCtor = new <
  T extends Entity,
  Relations extends object
>(
  entityClass: typeof Entity & {prototype: T},
  dataSource: object,
) => EntityCrudRepository<T, Relations>;

/**
 * A test-suite builder wants to accept any `EntityCrudRepository` 
 * implementation, so that we can define the same set of tests
 * for different repository classes.
 */
export function crudRepositoryTestSuite(
  dataSourceOptions: object,
  repositoryClass: CrudRepositoryCtor,
) {
  // write tests calling `repositoryClass` ctor to obtain
  // an instance of the repository for the given Entity and DataSource
  // (entities are defined by individual test cases)
}

/**
 * Usage in tests.
 */
//describe('DefaultCrudRepository with memory connector', () => {
  crudRepositoryTestSuite(
    {connector: 'memory'},
    DefaultCrudRepository,
  );
//});

Expected behavior:

The program compiles with no errors.

Actual behavior:

$ tsc --lib es2017 tsbug.ts
tsbug.ts:46:5 - error TS2345: Argument of type 'typeof DefaultCrudRepository' is not assignable to parameter of type 'CrudRepositoryCtor'.
  Type 'DefaultCrudRepository<Entity, {}>' is not assignable to type 'EntityCrudRepository<T, Relations>'.
    Types of property 'findFirst' are incompatible.
      Type '() => Promise<Entity>' is not assignable to type '() => Promise<T & Relations>'.
        Type 'Promise<Entity>' is not assignable to type 'Promise<T & Relations>'.
          Type 'Entity' is not assignable to type 'T & Relations'.
            Type 'Entity' is not assignable to type 'T'.
              'Entity' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Entity'.

46     DefaultCrudRepository,
       ~~~~~~~~~~~~~~~~~~~~~


Found 1 error.

This part seems most relevant to me:

Type 'DefaultCrudRepository<Entity, {}>' is not assignable to type 'EntityCrudRepository<T, Relations>'

IIUC, the compiler is not able to treat DefaultCrudRepository as a generic constructor, instead if specializes the constructor function too early using "sensible" default values for generic arguments.

Playground Link:

the link

Unfortunately, I am not able to reproduce the problem in the Playground.

Related Issues:

@bajtos
Copy link
Author

bajtos commented Jun 10, 2019

As a workaround, I am trying to use a factory function instead of a constructor, but this does not work either :(

// Replace CrudRepositoryCtor with CrudRepositoryFactory

export type CrudRepositoryFactory = <
  T extends Entity,
  Relations extends object
>(
  entityClass: typeof Entity & {prototype: T},
  dataSource: object,
) => EntityCrudRepository<T, Relations>;

export function crudRepositoryTestSuite(
  dataSourceOptions: object,
  repositoryClass: CrudRepositoryFactory,
) {
}

crudRepositoryTestSuite(
  {connector: 'memory'},
  <T extends Entity, Relations extends object>(entityClass, dataSource) => {
    return new DefaultCrudRepository<T, Relations>(entityClass, dataSource);
  }
);

The compiler complains again:

$ tsc --lib es2017 tsbug.ts
tsbug.ts:51:5 - error TS2345: Argument of type '<T extends Entity, Relations extends object>(entityClass: any, dataSource: any) => DefaultCrudRepository<T, Relations>' is not assignable to parameter of type 'CrudRepositoryFactory'.
  Type 'DefaultCrudRepository<Entity, object>' is not assignable to type 'EntityCrudRepository<T, Relations>'.
    Types of property 'findFirst' are incompatible.
      Type '() => Promise<Entity & object>' is not assignable to type '() => Promise<T & Relations>'.
        Type 'Promise<Entity & object>' is not assignable to type 'Promise<T & Relations>'.
          Type 'Entity & object' is not assignable to type 'T & Relations'.
            Type 'Entity & object' is not assignable to type 'T'.
              'Entity & object' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Entity'.

51     <T extends Entity, Relations extends object>(entityClass, dataSource) => {
       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 1 error.

Strangely enough, when add more types to the repository-factory, the compiler is able to preserve the first generic type T, but still specializes Relations as object.

Code:

  crudRepositoryTestSuite(
    {connector: 'memory'},
    <T extends Entity, Relations extends object>(
      entityClass: typeof Entity & {prototype: T},
      dataSource: object,
    ) => {
      return new DefaultCrudRepository<T, Relations>(entityClass, dataSource);
    }
  );

Error message:

$ tsc --lib es2017 tsbug.ts
tsbug.ts:51:5 - error TS2345: Argument of type '<T extends Entity, Relations extends object>(entityClass: typeof Entity & { prototype: T; }, dataSource: object) => DefaultCrudRepository<T, Relations>' is not assignable to parameter of type 'CrudRepositoryFactory'.
  Type 'DefaultCrudRepository<T, object>' is not assignable to type 'EntityCrudRepository<T, Relations>'.
    Types of property 'findFirst' are incompatible.
      Type '() => Promise<T & object>' is not assignable to type '() => Promise<T & Relations>'.
        Type 'Promise<T & object>' is not assignable to type 'Promise<T & Relations>'.
          Type 'T & object' is not assignable to type 'T & Relations'.
            Type 'Entity & object' is not assignable to type 'T & Relations'.
              Type 'Entity & object' is not assignable to type 'T'.
                'Entity & object' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Entity'.
                  Type 'T & object' is not assignable to type 'Relations'.
                    'T & object' is assignable to the constraint of type 'Relations', but 'Relations' could be instantiated with a different subtype of constraint 'object'.
                      Type 'Entity & object' is not assignable to type 'Relations'.
                        'Entity & object' is assignable to the constraint of type 'Relations', but 'Relations' could be instantiated with a different subtype of constraint 'object'.

51     <T extends Entity, Relations extends object>(
       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
52       entityClass: typeof Entity & {prototype: T},
   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
53       dataSource: object,
   ~~~~~~~~~~~~~~~~~~~~~~~~~
54     ) => {
   ~~~~~~~~~~


Found 1 error.

@bajtos
Copy link
Author

bajtos commented Jun 10, 2019

If this is not a bug and the compiler behaves correctly, what's the correct way for expressing my intent?

At the moment, I am able to work around the issue by using an explicit cast, which is not ideal.

  crudRepositoryTestSuite(
    {connector: 'memory'},
    DefaultCrudRepository as CrudRepositoryCtor
  );

I'd like to find a solution not requiring any explicit casts.

@RyanCavanaugh
Copy link
Member

TypeScript can't assume that two generic parameters originating in two different locations are "the same" just because they have the same names or same cardinality of parent type parameter list. Effectively, the T in one type is not the same as the T in the other. This is where you'd see implementation problems inside the body of crudRepositoryTestSuite -- invoking a generic type with type parameters, as given there, doesn't imply that you'll get out a conforming type the way you expect.

The simpler way to examine this is with a shorter repro:

export interface EntityCrudRepository<T, Relations extends object> {
  findFirst(): Promise<T & Relations>;
}
declare class DefaultCrudRepository<T, Relations extends object> {
  findFirst(): Promise<T & Relations>;
}

export type CrudRepositoryCtor1 = new <T, Relations extends object>() => EntityCrudRepository<T, Relations>;
declare function crudRepositoryTestSuite1(repositoryClass: CrudRepositoryCtor1): void;
crudRepositoryTestSuite1(DefaultCrudRepository);

export type CrudRepositoryCtor2 = new () => EntityCrudRepository<unknown, object>;
declare function crudRepositoryTestSuite2(repositoryClass: CrudRepositoryCtor2): void;
crudRepositoryTestSuite2(DefaultCrudRepository);

Here we also see the right way to declare the testSuite function.

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Jun 25, 2019
@bajtos
Copy link
Author

bajtos commented Jun 25, 2019

Thank you Ryan for the answer. I'll need to spend more time to better understand what you wrote :)

Here we also see the right way to declare the testSuite function.

IIUC your example, you are proposing to declare a new function overload for every repository class supported by the test suite. I find that problematic, because it requires the test suite to know about all possible implementations of EntityCrudRepository interface, which may not be possible if those implementations live in 3rd party npm packages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants