In TypeScript, the combination of generics and narrowing doesn't blend well together. When you utilize is(this, 'foo')
, the type guard function can narrow down the apparent type of this
to this & MyClass<"foo">
, but it doesn't impact the generic type parameter T
at all. The expectation that T
would be constrained from Type
to just "foo"
is not met. Consequently, {foo: 123}
might not be proven assignable to Res[T]
, resulting in an error.
It may seem intuitive that this.type === "foo"
should imply that T
is "foo"
, but this assumption is incorrect in general. This is because T
could potentially be part of the union type "foo" | "bar"
, and confirming that this.type === "foo"
wouldn't exclude that possibility entirely. At best, it can be said that you know T
must intersect with
"foo"</c/>, making <code>T & "foo"
not equal to
never
, although enforcing or expressing such a constraint is challenging.
There have been multiple requests for features aimed at re-constraining generic type parameters through control flow analysis. Two significant ones related to this example are microsoft/TypeScript#27808, which aims to restrict T
to only one of "foo" and "bar" rather than the entire union; and microsoft/TypeScript#33014, which suggests that even if T
was the complete union, returning {foo: 123}
would be safe. While either of these changes would likely resolve the issue, they are not currently incorporated into the language, necessitating workarounds.
The simplest workaround involves using type assertions to bypass strict type checking by the compiler:
public run(): Res[T] {
if (is(this, 'foo')) {
return { foo: 123 } as Res[T]; // fine
}
return { bar: 'xyz' } as Res[T]; // fine
}
This provides a quick solution with minimal modifications to your code. However, keep in mind that by employing type assertions, you assume the responsibility for accurate type declarations, as evidenced by the compiler accepting (!is(this, 'foo'))
.
If ensuring type safety guarantees from the compiler takes precedence over simplicity, refactoring away from type guarding and towards generic indexing can achieve the desired outcome. Instead of relying on control flow analysis, you can implement an indexed access type like so:
public run(): Res[T] {
return {
foo: { foo: 123 },
bar: { bar: 'xyz' }
}[this.type]; // valid
}
While the logic might appear unconventional, this approach resembles the behavior of inspecting this.type
and directing control flow based on the result. One notable distinction is that both potential return values {foo: 123}
and {bar: 'xyz'}
are computed during each execution of run()
, albeit discarding one eventually. For simple object literals without side effects, the overhead is negligible. To replicate the previous behavior precisely, consider utilizing getters to execute only the appropriate code block corresponding to the actual this.type
value:
public run(): Res[T] {
return {
get foo() { return { foo: 123 } },
get bar() { return { bar: 'xyz' } }
}[this.type]; // valid
}
In this scenario, when this.type
is "foo"
, return {foo: 123}
runs while return {bar: 'xyz'}
does not.
Although adjustments can be made, the core concept revolves around implementing an indexed access type via an actual indexing operation instead of contingent statements like if
/else
.
To summarize, overcoming the limitations posed by the current absence of synergy between generics and type guards in TypeScript requires either relaxing type checks or leveraging generics independent of type guards.
Playground Link