nitowa
nitowa

Reputation: 1107

Type for functions that accept inherited type

Suppose the following inheritance structure of config objects

class ConfBase{
  constructor(public baseVal:any){}
}

class AConf extends ConfBase{
    constructor(public a: any) {
      super("A")
  }
}

class BConf extends ConfBase{
    constructor(public b: any) {
      super("B")
  }
}

with a corresponding structure of services

class ServiceBase<T>{
    constructor(conf: ConfBase) {
        //...
    }
}

type AType = {/* ... */}
class AService extends ServiceBase<AType>{
    constructor(conf: AConf) {
        super(conf)
        //...
    }
}

type BType = {/* ... */}
class BService extends ServiceBase<BType>{
    constructor(conf: BConf) {
        super(conf)
        //...
    }
}

Now I'd like to create a map of functions that take config objects and creates a corresponding service objects.

let serviceFactory:Map<string, (conf: ConfBase) => ServiceBase<any>> = new Map()
serviceFactory.set('A', (aConf: AConf) => { return new AService(aConf) })
serviceFactory.set('B', (bConf: BConf) => { return new BService(bConf) })

However, when TSC is configured to use strictFunctionTypes: true the following error is produced:

Argument of type '(aConf: AConf) => AService' is not assignable to parameter of type '(conf: ConfBase) => ServiceBase'.

Types of parameters 'aConf' and 'conf' are incompatible. Property 'a' is missing in type 'ConfBase' but required in type 'AConf'.

Is there a way to make the type system agree with my idea? The dirty workaround is to just define the config object as conf:any, but I'd much rather use the equivalent of java's ? extends ConfBase. Is there such a thing?

Upvotes: 0

Views: 58

Answers (1)

jcalz
jcalz

Reputation: 329533

It looks like you're not really interested in TypeScript preventing you from doing this:

const aFactory = serviceFactory.get("A");
if (aFactory) {
    aFactory(new BConf("oopsie")); // no error
}

Given the type of serviceFactory, that code will compile with no error and then give you weird results at runtime. Also, even if you passed in the right config, the return type is ServiceBase<any>, not ServiceBase<AType> so you'd be taking the responsibility for asserting the type yourself.

If you're comfortable with all that, then the way to get the compiler to acquiesce is:

serviceFactory.set('A', (aConf: ConfBase) => { return new AService(aConf as AConf) })
serviceFactory.set('B', (bConf: ConfBase) => { return new BService(bConf as BConf) })

That is, you have the functions accept any ConfBase and then just tell the compiler that you will be careful at runtime only to use AConf for the first one and BConf for the second one.


If you care more about type safety then you'll need to refactor this code significantly by telling the compiler about the relationship between the name passed into serviceFactory and the particular service being constructed.

Here's one possible way forward. Let's leave this part unchanged:

class ConfBase {
  constructor(public baseVal: string) { }
}

class AConf extends ConfBase {
  constructor(public a: any) {
    super("A")
  }
}

class BConf extends ConfBase {
  constructor(public b: any) {
    super("B")
  }
}

Then for the services, we need to make them generic and refer to the corresponding ConfBase type as well as the relevant "type" like AType or BType.

abstract class ServiceBase<C extends ConfBase, T> {
  conf: C
  constructor(conf: C) {
    this.conf = conf;
  }
  // shouldn't ServiceBase<T> do something with T? 
  abstract getValueOfTypeOrSomething(): T;
}

type AType = { a: string } // for example
class AService extends ServiceBase<AConf, AType>{

  constructor(conf: AConf) {
    super(conf)
  }
  getValueOfTypeOrSomething() {
    return { a: "whoKnows" };
  }

}

type BType = { b: number } // for example
class BService extends ServiceBase<BConf, BType>{
  constructor(conf: BConf) {
    super(conf)
  }
  getValueOfTypeOrSomething() {
    return { b: 12345 };
  }
}

Now we can describe the mapping from name "A" and "B" to service as ServiceMap:

// describe the mapping from name to service
interface ServiceMap {
  A: AService
  B: BService
}

And the service factory can be a mapped type related to ServiceMap:

let serviceFactory: { [K in keyof ServiceMap]: (conf: ServiceMap[K]['conf']) => ServiceMap[K] } = {
  A: (aConf: AConf) => { return new AService(aConf) },
  B: (bConf: BConf) => { return new BService(bConf) }
}

A Map isn't a great representation because it acts like a dictionary where all the entries are the same type. But here you want to remember that the "A"-keyed entry specifically is an AService-maker. So then the following is type-safe:

const aFactory = serviceFactory.A;
aFactory(new BConf("oopsie")); // error, as desired
const aService = aFactory(new AConf("yay")); // okay
// aService is known to be an AService
const aType = aService.getValueOfTypeOrSomething();
// aType is known to be an AType

Okay, hope that helps. Good luck!

Upvotes: 1

Related Questions