5

I would like to have a function that returns an object instance based on a given identifier (e.g. string or symbol). In code it would look maybe something like:

// define your services
type ServiceA = { foo: () => string };
const ServiceA = { foo: () => 'bar' };

type ServiceB = { bar: () => number };
const ServiceB = { bar: () => 1 };

// register your services with identifier
registry.register('serviceA', ServiceA);
registry.register('serviceB', ServiceB);

// get a service by its type identifier
// the important thing here: I want to know the type!
const serviceA: ServiceA = registry.get('serviceA');
serviceA.foo();

A further requirement: ServiceA and ServiceB do not share an interface or so, they can be totally different.

The challenge in the shown example is to know the exact type of the value returned by registry.get.

I tried an naive approach like:

enum ServiceType {
  A,
  B
}

const getService = (type: ServiceType): ServiceA | ServiceB => {
  switch (type) {
    case ServiceType.A:
      return ServiceA;
    case ServiceType.B:
      return ServiceB;
    default:
      throw new TypeError('Invalid type');
  }
};

and also the same thing with if but the compiler is not able to derive the concrete type of the returned value, when I execute const x = getService(ServiceType.A); the type of x is ServiceA | ServiceB, what I would like to see is ServiceA.

Is there any way to do something like this? If not, what is the reason for that from a compiler point of view?

B zum S
  • 258
  • 3
  • 10

2 Answers2

16

If you know in advance the type of services you will get back from your registry, then you can use a type representing a mapping from string keys to service types:

type ServiceMapping = {
  // first ServiceA is a string key, second is a type
  ServiceA: ServiceA;  
  // first ServiceB is a string key, second is a type
  ServiceB: ServiceB;
}

function getService<T extends keyof ServiceMapping>(type: T): ServiceMapping[T] {
  return ({
    ServiceA: ServiceA,
    ServiceB: ServiceB
  })[type];
}

// use it
const serviceA = getService('ServiceA'); 
serviceA.foo();  // works

If you want a registry you can keep adding things to and keep track of the types at compile time, you'll need to use a chain of registry objects. You can't really use a constant registry object because TypeScript won't let you mutate the type of the object based on what you register. But you can help TypeScript along by making the registry return a new type of object when you call its register() method, and you only hold onto the returned object. Like this:

class ServiceRegistry<T> {  
  private constructor(private registry: T) {}
  register<K extends string, S>(key: K, service: S): ServiceRegistry<Record<K, S> & T> {    
    // add service to registry and return the same object with a narrowed type
    (this.registry as any)[key] = service;
    return this as any as ServiceRegistry<Record<K, S> & T>;
  }
  get<K extends keyof T>(key: K): T[K] {
    if (!(key in this.registry)) {
      throw new Error('Invalid type' + key);
    }
    return this.registry[key];
  }
  static init(): ServiceRegistry<{}> {
    return new ServiceRegistry({});
  }
}

So here's how you use it:

// register things
const registry = ServiceRegistry.init()
  .register('ServiceA', ServiceA)
  .register('ServiceB', ServiceB);

Note that the type of registry is the type of the return value of the last register() method, which has all the relevant mappings from type key to service object.

const serviceA = registry.get('ServiceA');
serviceA.foo(); // works

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
2

I'm not really sure about your requirements but maybe you can use overloads:

function getService(serviceName:"ServiceA"): typeof ServiceA;
function getService(serviceName:"ServiceB"): typeof ServiceB;
function getService(serviceName:string): object {
    switch(serviceName) {
        case "ServiceA":
            return ServiceA;
        case "ServiceB":
            return ServiceB;
        default:
            return null;
    }
}

Inspired by the answer of jcalz, you can alternatively use a JavaScript object as base for the registry:

const serviceMap = {
    "ServiceA": ServiceA,
    "ServiceB": ServiceB,
};

function getService<K extends keyof typeof serviceMap>(serviceName:K): typeof serviceMap[K] {
    return serviceMap[serviceName];
}

If you want to extend the service map in other modules, you can use a global interface. This is similar to what TypeScript itself uses for addEventListener and maybe other functions.

declare global {
    interface ServiceMap {
        ["ServiceA"]: ServiceA,
        ["ServiceB"]: ServiceB,
    }
}

function getService<K extends keyof ServiceMap>(serviceName:K): ServiceMap[K] {
    // Get service somehow.
}

Other modules can than add additional services by extending the interface.

declare global {
    interface ServiceMap {
        ["ServiceC"]: ServiceC,
    }
}

// register service "ServiceC"
JojOatXGME
  • 3,023
  • 2
  • 25
  • 41
  • Woks as well but does not scale so nicely imo. – B zum S Nov 03 '17 at 18:21
  • @BzumS Yea, you are right, but since it is a very simple solution, I wanted to list it here. I also added another suggestion inspired by the first suggestion [of jcalz](https://stackoverflow.com/a/47098963/4967497). – JojOatXGME Nov 03 '17 at 19:30