Rasmus Faber
Rasmus Faber

Reputation: 49619

Generic factory methods in Typescript with interfaces

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).

Upvotes: 0

Views: 134

Answers (1)

jcalz
jcalz

Reputation: 327934

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': // 😱
      return getFoo(id) as TypeMapper[K]; // no error
    case 'Foo': // 😱
      return getBar(id) as TypeMapper[K]; // no error
    default:
      throw "Unknown type";
  }
}

So you might want an approach where the compiler actually helps with type safety instead.


Another approach is to refactor so that the indexed access type corresponds to an actual indexed access. That is, represent the TypeMapper mapping interface as an actual object into which you look up with type as the key. Something like:

function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  const typeMapper: TypeMapper = {
    Foo: getFoo(id),
    Bar: getBar(id)
  }
  return typeMapper[type];
}

That works just fine, because the compiler is able to verify that indexing into a value of type TypeMapper with a key of type K produces a value of type TypeMapper[K]. Hooray!

Except, uh, that typeMapper object is going to run getXXX(id) for every XXX type, all but one of which will be useless calls, at best. Really we want to refactor so that we look up the getXXX() function by type, and then call just that function with id:

function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  const typeMapper: { [P in keyof TypeMapper]: (id: string) => TypeMapper[P] } = {
    Foo: getFoo,
    Bar: getBar
  }
  return typeMapper[type](id);
}

Now that really does work fine, because you're only calling the correct function. You could now refactor this... presumably we could use a static typeMapper that lives outside of the function and is reused:

const typeMapper: { [K in keyof TypeMapper]: (id: string) => TypeMapper[K] } = {
  Foo: getFoo,
  Bar: getBar
}
function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  return typeMapper[type](id);
}

That's just about as far as we can go, except that it seems that instead of defining TypeMapper, we should be able to derive the TypeMapper type from the typeMapper value. I'll spare the detailed explanation, but such a derivation might look like this:

const _typeMapper = {
  Foo: getFoo,
  Bar: getBar
}
type TypeMapper = { [K in keyof typeof _typeMapper]: ReturnType<typeof _typeMapper[K]> };
const typeMapper: { [K in keyof TypeMapper]: (id: string) => TypeMapper[K] } = 
  _typeMapper;

function get<K extends keyof TypeMapper>(type: K, id: string): TypeMapper[K] {
  return (typeMapper[type])(id);
}

And there you go. Now every time you add a new interface, you can just add an entry to _typeMapper, and everything just works:

interface Baz { c: boolean }
declare function getBaz(id: string): Baz;

const _typeMapper = {
  Foo: getFoo,
  Bar: getBar,
  Baz: getBaz, // add this
}

const baz = get("Baz", "ghi");
// const baz: Baz

Playground link to code

Upvotes: 1

Related Questions