Search code examples
javascripttypescriptinheritancetypeof

Getting error: Type 'typeof B' is not assignable to type 'typeof A' for class B that extends A


Reproducible example here

My need is: the contentType parameter should accept any class object extended from Content (PublicContent, AdminContent, PrivateContent, etc) and I want to call a static method from this parameter type inside the execute method.

I have a method with the following signature:

async execute<U extends ContentProps>(input: {
    contentType: typeof Content;
    contentPropsType: typeof ContentProps; 
}): Promise<Result<U, Failure>>;

and a class hierarchy as follows:

// content.entity.ts

export class ContentProps extends EntityProps {}

export class Content<T extends ContentProps> extends Entity<T> {
  public constructor(props: T) {
    super(props);
  }
}

// public-content.entity.ts
export class PublicContentProps extends ContentProps {
  readonly title: string;
  readonly text: string;
}

export class PublicContent extends Content<PublicContentProps> {
  constructor(props: PublicContentProps) {
    super(props);
  }
  // ommited
}

The issue is that when I call the execute method passing PublicContent as the contentType parameter I'm getting an error saying

Type 'typeof PublicContent' is not assignable to type 'typeof Content'

The method call is:

const result = await this.getContent.execute({
  contentType: PublicContent,
  contentPropsType: PublicContentProps,
});

My question is: Why I'm getting this error since PublicContent is extending Content?

EDIT: as requested by @Chase, the full types for Entity and EntityProps:

// entity.ts
export abstract class EntityProps extends BaseEntityProps {
  id?: string;
  createdAt?: Date;
  updatedAt?: Date;
}

export abstract class Entity<T extends EntityProps> extends BaseEntity<T> {
  get id(): string {
    return this.props.id;
  }

  get createdAt(): Date {
    return this.props.createdAt;
  }

  get updatedAt(): Date {
    return this.props.updatedAt;
  }

  protected constructor(entityProps: T) {
    super(entityProps);
  }
}


// base.entity.ts
export abstract class BaseEntityProps {}

export abstract class BaseEntity<T extends BaseEntityProps> extends Equatable {
  protected readonly props: T;

  protected constructor(baseEntityProps: T) {
    super();
    this.props = baseEntityProps;
  }

  static create<T = BaseEntity<BaseEntityProps>, U = BaseEntityProps>(
    this: {
      new (entityProps: U): T;
    },
    propsType: { new (): U },
    props: U,
  ): Result<T, ValidationFailure> {
    const violations = validateSchemaSync(propsType, props);

    return violations?.length
      ? Result.fail(new ValidationFailure(violations))
      : Result.ok(new this({ ...props }));
  }

  toJSON(): T {
    return this.props;
  }
}

Solution

  • The problem you're running into is that superclass/subclass constructors do not always form a type hierarchy even if their instances do. Let's look at an example:

    class Foo {
      x = 1;
      constructor() { }
      static z = 3;
    }
    
    class Bar extends Foo {
      y: string;
      constructor(y: number) {
        super()
        this.y = y.toFixed(1);
      }
    }
    

    Here, class Bar extends Foo means if you have a value of type Bar, you can assign it to a variable of type Foo:

    const bar: Bar = new Bar(2);
    const foo: Foo = bar; // okay
    

    But if you try to assign the Bar constructor (of type typeof Bar) to a value of the same type as the Foo constructor (of type typeof Foo), it fails:

    const fooCtor: typeof Foo = Bar; // error!
    // Type 'new (y: number) => Bar' is not assignable to type 'new () => Foo'
    

    That's because the Bar constructor requires a parameter of type number when you call its construct signature (i.e., new Bar(2)), while the Foo constructor takes no parameters at all (i.e., new Foo()). If you try to use Bar as if it were the Foo constructor, and call it with no parameters, you'll get a runtime error:

    const oopsAtRuntime = new fooCtor(); // TypeError: y is undefined
    

    For the same reason, your PublicContent constructor is not assignable to typeof Content. The former requires a construct signature parameter of type PublicContentProps, while the latter will accept any parameter of type that extends ContentProps. If you try to use PublicContent as if it were the Content constructor, you might pass it a parameter of some type other than PublicContentProps, and that could lead to errors.


    So let's step back. In fact you don't care if the object you pass as contentType is assignable to the Content constructor type because you're not going to call its construct signature with an arbitrary ContentProps. You really only care about the type of its static create() method. I'd be inclined to write getContent() as a generic function like this:

    const getContent = <U extends ContentProps, T extends BaseEntity<U>>(input: {
      contentType: Pick<typeof Content, "create"> & (new (entityProps: U) => T);
      contentPropsType: new () => U;
    }): U => { /* impl */ };
    

    That should work similarly to your existing version on the inside of the function, and now you can call it without error, because PublicContent matches the create method of Content, as well as being a constructor of type (new (entityProps: PublicContentProps) => PublicContent):

    const entity = getContent({
      contentType: PublicContent,
      contentPropsType: PublicContentProps,
    }); // okay
    

    Playground link to code