Issue
How to make work in TS a generic class that can return different types based on its generic parameter (set in the constructor)?
type Type = 'foo' | 'bar';
interface Res {
'foo': {foo: number};
'bar': {bar: string};
}
class MyClass<T extends Type> {
constructor(readonly type: T) {}
public run(): Res[T] {
if (is(this, 'foo')) {
return { foo: 123 };
}
return { bar: 'xyz' };
}
}
function is<T extends Type>(instance: MyClass<Type>, type: T): instance is MyClass<T> {
return instance.type === type;
}
As it can be checked in the Playground, this example gives the following error:
Type '{ foo: number; }' is not assignable to type 'Res[T]'. Type '{ foo: number; }' is not assignable to type '{ foo: number; } & { bar: string; }'. Property 'bar' is missing in type '{ foo: number; }' but required in type '{ bar: string; }'.
but common sense (and the type guard) is stating that the generic T
is clearly the correct type... so why is it not applied to the return line?
Solution
Currently generics and narrowing don't really work very well together in TypeScript. When you check is(this, 'foo')
, the type guard function can narrow the apparent type of this
to this & MyClass<"foo">
, but it has no effect whatsoever on the generic type parameter T
. You might expect or hope that T
would be re-constrained from Type
to just "foo"
, but it's not. And that means {foo: 123}
cannot be known to be assignable to Res[T]
, and you get an error.
Note that while it might seem "obvious" that this.type === "foo"
should imply T
is "foo"
, it is actually incorrect to do so in general. That's because T
might be specified by the full union type "foo" | "bar"
, and establishing that this.type === "foo"
would not let you eliminate that possibility. At best you can say that you know that T
must have some overlap with "foo"
, so that T & "foo"
is not never
, but this is very tricky sort of constraint to enforce or express.
There are several open feature requests asking for some way of re-constraining generic type parameters via control flow analysis. The most relevant ones for this code example here are microsoft/TypeScript#27808 which would let you force T
to only be one of "foo"
and "bar"
and never the union; and microsoft/TypeScript#33014 which would let the compiler see that even if T
were the full union, it would be safe to return {foo: 123}
. Either one would probably make your code work as-is, but for now they are not part of the language and you need to work around it.
The easiest workaround is just to use type assertions to tell the compiler not to worry about verifying the types. For example:
public run(): Res[T] {
if (is(this, 'foo')) {
return { foo: 123 } as Res[T]; // okay
}
return { bar: 'xyz' } as Res[T]; // okay
}
This is the most expedient way for you to move forward with a minimum of changes to your code. But just beware that you're taking the responsibility for type checking away from the compiler here. You could change the check above to (!is(this, 'foo'))
and the compiler would still be happy with it. So you have to be careful when making type assertions that you are telling the truth.
If it's more important to get some type safety guarantees from the compiler here, then the workaround will involve refactoring away from type guarding and control flow analysis, and toward generic indexing. Your intended return type is the indexed access type Res[T]
, so if you want the compiler to understand that, you can index into an object of type Res
with a key of type T
and return that:
public run(): Res[T] {
return {
foo: { foo: 123 },
bar: { bar: 'xyz' }
}[this.type]; // okay
}
That might look weird but it is similar to the logic of checking this.type
and then directing control flow based on the result. The only major difference here is that the runtime will actually compute both possible return values {foo: 123}
and {bar: 'xyz'}
every time run()
is called, even though it throws away one of them. For small object literals without side effects like this, it probably doesn't matter. But if you really want to have the same behavior as before, you can use getters to make sure that only the code block corresponding to the actual this.type
value is executed:
public run(): Res[T] {
return {
get foo() { return { foo: 123 } },
get bar() { return { bar: 'xyz' } }
}[this.type]; // okay
}
Now id this.type
is "foo"
, return {foo: 123}
will run but return {bar: 'xyz'}
will not.
You could modify this implementation to do other things, but the basic approach is that you are implementing an indexed access type via an actual indexing operation, and not via if
/else
statements.
So there you go. The language is not ready for generics and type guards together, and you can work around it either by relaxing the type checking, or by using generics without type guards.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.