Home > Blockchain >  Generic factory methods in Typescript with interfaces
Generic factory methods in Typescript with interfaces

Time:11-01

I have a set of interfaces in my Typescript code (autogenerated by OpenAPI Generator) and I would like to implement a generic method something like this:

interface Foo {}
function getFoo(id: string): Foo { /* ... */ }

interface Bar {}
function getBar(id: string): Bar { /* ... */ }

function get<T>(type: Type<T>, id: string): T {
    switch(type) {
        case typeof Foo:
            return getFoo(id);
        case typeof Bar:
            return getBar(id);
        default:
            throw "Unknown type";
    }
}

Is that possible?

If Foo and Bar were classes, I could have used

function get<T>(type: new() => T, id: string): T {
    switch(typeof new type()){
        // ...
    }
}

, but this doesn't work with interfaces.

I could tag the interfaces and do

interface Foo { type: 'foo'; }
interface Bar { type: 'bar'; }

function get<T>(type: string, id: string): T {
    switch(type) {
        case 'foo':
            return getFoo(id);
        case 'bar':
            return getBar(id);
        default:
            throw "Unknown type";
    }
}

, but I don't see a way to preventing something like get<Bar>('foo', id).

CodePudding user response:

In order for this to possibly work, you'd need some mapping between genuine values, like the strings "Foo" and "Bar", to the corresponding interface types, like Foo and Bar. You'd pass a value into get(), along with an id of some sort, and then get a value of the corresponding interface type.

So, what values should we use? Strings are a good choice since they're easy to come by, and there's a very straightforward way to represent mappings between string literal types and other types: object types. They are already mappings between keys (of string literal types) and values (or arbitrary types).

For example:

interface TypeMapper {
   "Foo": Foo;
   "Bar": Bar;
}

Which can equivalently be written

interface TypeMapper {
    Foo: Foo;
    Bar: Bar;
}

Armed with a type like that, then get() should have the generic call signature

declare function get<K extends keyof TypeMapper>(
  type: K, id: string
): TypeMapper[K];

whereby the type input is of type K constrained to keyof TypeMapper, and the output is of the indexed access type TypeMapper[K].

Let's just imagine we already have that implemented, and make sure you could call it as desired:

const foo = get("Foo", "abc");
// const foo: Foo
foo.a; // it's a Foo

const bar = get("Bar", "def");
//const bar: Bar

Looks good.


Now for an implementation of get(). You could write it similar to in your question:

function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  switch (type) {
    case 'Foo':
      return getFoo(id); // error! Type 'Foo' is not assignable to type 'TypeMapper[K]'.
    case 'Bar':
      return getBar(id); // error! Type 'Bar' is not assignable to type 'TypeMapper[K]'.
    default:
      throw "Unknown type";
  }
}

This works at runtime, and the typings are correct, but unfortunately the compiler can't verify that. When you check type with case 'Foo', it can narrow down type from type K to "Foo", but it doesn't know how to narrow the type parameter K itself, and so it doesn't see that a value of type Foo is assignable to TypeMapper[K]. This is currently a limitation of TypeScript, and there are various open feature requests asking for some improvement. For example, microsoft/TypeScript#33014. Until and unless such a feature is implemented, you will need to work around the limitation.

The easiest approach is to just suppress the errors with type assertions:

function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  switch (type) {
    case 'Foo':
      return getFoo(id) as TypeMapper[K];
    case 'Bar':
      return getBar(id) as TypeMapper[K];
    default:
      throw "Unknown type";
  }
}

That works, but now you have the responsibility of implementing it properly, since the compiler can't. If you had swapped case 'Foo' with case 'Bar', the compiler wouldn't have noticed:

function getTwoBad<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  switch (type) {
    case 'Bar': //            
  • Related