Reputation: 49619
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
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
Upvotes: 1