在 TypeScript 中实现类型安全的服务注册表



我想要一个基于给定标识符(例如字符串或符号)返回对象实例的函数。在代码中,它看起来像这样:

// 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();

进一步的要求:ServiceAServiceB不共享一个接口,因此它们可能完全不同。

所示示例中的挑战是知道 registry.get 返回的值的确切类型。

我尝试了一种天真的方法,例如:

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');
  }
};
if也是如此,

但编译器无法导出返回值的具体类型,当我执行const x = getService(ServiceType.A); x 的类型ServiceA | ServiceB时,我希望看到的是 ServiceA .

有没有办法做这样的事情?如果不是,从编译器的角度来看,这是什么原因?

如果您事先知道将从注册表中返回的服务类型,则可以使用表示从字符串键到服务类型的映射的类型:

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
<小时 />

如果你想要一个注册表,你可以在编译时继续添加内容并跟踪类型,你需要使用注册表对象链。 您不能真正使用常量注册表对象,因为 TypeScript 不会让您根据注册的内容改变对象的类型。 但是,您可以通过在调用注册表register()方法时使注册表返回新类型的对象来帮助TypeScript,并且仅保留返回的对象。 喜欢这个:

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({});
  }
}

因此,以下是您的使用方式:

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

请注意,registry 的类型是最后一个register()方法的返回值的类型,它具有从类型键到服务对象的所有相关映射。

const serviceA = registry.get('ServiceA');
serviceA.foo(); // works
<小时 />

操场链接到代码

我不太确定您的要求,但也许您可以使用重载:

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;
    }
}

受到 jcalz 答案的启发,您也可以使用 JavaScript 对象作为注册表的基础:

const serviceMap = {
    "ServiceA": ServiceA,
    "ServiceB": ServiceB,
};
function getService<K extends keyof typeof serviceMap>(serviceName:K): typeof serviceMap[K] {
    return serviceMap[serviceName];
}

如果要在其他模块中扩展服务映射,可以使用全局接口。这类似于TypeScript本身用于addEventListener和其他函数的功能。

declare global {
    interface ServiceMap {
        ["ServiceA"]: ServiceA,
        ["ServiceB"]: ServiceB,
    }
}
function getService<K extends keyof ServiceMap>(serviceName:K): ServiceMap[K] {
    // Get service somehow.
}
其他

模块可以通过扩展接口来添加其他服务。

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

最新更新