Matthew Adams
Matthew Adams

Reputation: 2197

Why does this TypeScript mixin employing generics fail to compile?

I'm using mixins/traits with TypeScript using a subclass factory pattern as described at https://mariusschulz.com/blog/mixin-classes-in-typescript. The trait in question is called Identifiable, which imparts an id property to a class that should express the Identifiable trait. When I attempt to use the trait with another, non-generic trait (Nameable) in a certain order, compilation fails.

class Empty {}

type ctor<T = Empty> = new(...args: any[]) => T;

function Nameable<T extends ctor = ctor<Empty>>(superclass: T = Empty as T) {
  return class extends superclass {
    public name?: string;
  };
}

function Identifiable<ID, T extends ctor = ctor<Empty>>(superclass: T = Empty as T) {
  return class extends superclass {
    public id?: ID;
  };
}

class Person1 extends Nameable(Identifiable<string>()) { // compiles
  constructor(name?: string) {
    super();
    this.name = name;
    this.id = "none";
  }
}

class Person2 extends Identifiable<string>(Nameable()) { // fails to compile
  constructor(name?: string) {
    super();
    this.name = name;
    this.id = "none";
  }
}

Compilation error is

src/test/unit/single.ts:30:10 - error TS2339: Property 'name' does not exist on type 'Person2'.

30     this.name = name;
            ~~~~

How do I get generic traits to compile correctly, regardless of the order in which they're used?

NB: public git repo for this question is at https://github.com/matthewadams/typetrait. If you want to play with this, make sure to checkout the minimal branch.

Upvotes: 1

Views: 777

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249596

The issue is very simple actually, and it is related to the fact that typescript does not have partial type argument inference. The call Identifiable<string>(...) does not mean you set ID and let the compiler infer T. It actually means use string for ID and use the default (ie Empty) for T. This is unfortunate and there is a proposal to allow partial inference but it hasn't gained much traction.

You have two options, either use function currying to do a two call approach, where the first call passes ID and the second call infers T:

class Empty { }

type ctor<T = Empty> = new (...args: any[]) => T;

function Nameable<T extends ctor = ctor<Empty>>(superclass: T = Empty as T) {
  return class extends superclass {
    public name?: string;
  };
}

function Identifiable<ID>() {
  return function <T extends ctor = ctor<Empty>>(superclass: T = Empty as T) {
    return class extends superclass {
      public id?: ID;
    };
  }
}


class Person2 extends Identifiable<string>()(Nameable()) {
  constructor(name?: string) {
    super();
    this.name = name;
    this.id = "none";
  }
}

Playground link

Or use inference on ID as well by using a dummy parameter as an inference site:

class Empty { }

type ctor<T = Empty> = new (...args: any[]) => T;

function Nameable<T extends ctor = ctor<Empty>>(superclass: T = Empty as T) {
  return class extends superclass {
    public name?: string;
  };
}

function Identifiable<ID, T extends ctor = ctor<Empty>>(type: ID, superclass: T = Empty as T) {
    return class extends superclass {
      public id?: ID;
    };
  }
}


class Person2 extends Identifiable(null! as string, Nameable()) {
  constructor(name?: string) {
    super();
    this.name = name;
    this.id = "none";
  }
}

Playground link

Upvotes: 6

Related Questions