Search code examples
typescriptinterfaceabstraction

Constraints a member to just accept a type (not an instance) that extends or implements other type [TypeScript]


I need some help with a litter of abstractions in typescript.

I want to constraints a member to just accept types as values but those types need to implement or extend other types or interfaces.

Eg.

The following example does not represent or is related to a real data model, instead is an example to illustrate the goal.

interface Profession {
  work()
}

class Engineer implements Profession {
  work() {...}
}

class Doctor  {
  work() {...}
}

interface ProfessionRankingMap {
  top1ProfessionType: // Here is where I don't know how to constraint
  top2ProfessionType: // Here is where I don't know how to constraint
}

const classAProfessionRankingMap: ProfessionRankingMap {
  // This is okay, Type Engineer implements Profession interface
  top1ProfessionType: Engineer
  // This is bad, Type Doctor doesn't implement Profession interface
  top2ProfessionType: Doctor
}

const classBProfessionRankingMap: ProfessionRankingMap {
  // This is bad, [new Engineer()] returns an instance not a type
  top1ProfessionType: new Engineer()
  // This is bad, [new Doctor()] returns an instance not a type
  top2ProfessionType: new Doctor()
}

Solution

  • To express a class in a type, you need a constructor signature:

    interface ProfessionRankingMap {
      // The implementer must have a constructor with no args that returns Profession 
      top1ProfessionType: new () => Profession 
      top2ProfessionType: new () => Profession
    }
    
    const classAProfessionRankingMap: ProfessionRankingMap = {
      top1ProfessionType: Engineer,
      top2ProfessionType: Doctor
    }
    

    Playground link

    If we want to accept classes with constructors that have any number of arguments we can use new (... args:any[]) => Profession.

    The second part of your problem is a bit more complicated. Typescript uses structural typing to determine type compatibility. So the structure of the class matters not the implements clause. The implements clause will just help give you errors when you declare the class, ensuring that it has all the required members, so you get errors early not when you try to use the class where the interface is expected.

    What this means is that as long as Doctor has all the members of Profession there is no way to prevent it being passed in where Profession is expected.

    The only solution is to use an abstract class with a private member. The private member will ensure that no other class except those extending the abstract class will be compatible with the abstract class type:

    abstract class Profession {
      private _notStructural: undefined;
      abstract work(): void
    }
    
    class Engineer extends Profession {
        work() { }
    }
    
    class Doctor  {
        work() { }
    }
    
    interface ProfessionRankingMap {
      top1ProfessionType: new () => Profession
      top2ProfessionType: new () => Profession
    }
    
    const classAProfessionRankingMap: ProfessionRankingMap = {
      top1ProfessionType: Engineer,
      top2ProfessionType: Doctor // error
    }
    

    Playground link