Implementing a generic function with a conditional return type
Solution 1:
The underlying issue is that TypeScript's compiler does not narrow the type of a generic type variable via control flow analysis. When you check (typeof maybeNumber === "number")
, the compiler can narrow the value maybeNumber
to number
, but it does not narrow the type parameter T
to number
. And therefore it cannot verify that it's safe to assign a number
value to the return type T extends number ? number : string
. The compiler would have to perform some analysis it currently does not do, such as "okay, if typeof maybeNumber === "number"
, and we inferred T
from the type of maybeNumber
alone, then inside this block we can narrow T
to number
, and therefore we should return a value of type number extends number ? number : string
, a.k.a., number
". But this doesn't happen.
This is quite a pain point with generic functions with conditional return types. The canonical open GitHub issue about this is probably microsoft/TypeScript#33912, but there are a bunch of other GitHub issues out there where this is the main problem.
So that's the answer to "why doesn't this work"?
If you're not interested in refactoring to get this to work, you can ignore the rest, but it might still be instructive to know what to do in this situation instead of waiting for the language to change.
The most straightforward workaround here that maintains your call signature is to make your function a single signature overload where the implementation signature is not generic. This essentially loosens the type safety guarantees inside the implementation:
type MyConditional<T> = T extends number ? number : string;
type Unknown = string | number | boolean | {} | null | undefined;
function test<T>(maybeNumber: T): MyConditional<T>;
function test(maybeNumber: Unknown): MyConditional<Unknown> {
if (typeof maybeNumber === 'number') {
const ret: MyConditional<typeof maybeNumber> = maybeNumber;
return ret;
}
const ret: MyConditional<typeof maybeNumber> = "Not a number";
return ret;
}
Here I've gone about as far as I can go to try to guarantee type safety, by using a temporary ret
variable annotated as MyConditional<typeof maybeNumber>
which uses the control-flow-analysis-narrowed type of maybeNumber
. This will at least complain if you switch around the check (turn ===
into !==
to verify). But usually I just do something simpler like this and let the chips fall where they may:
function test2<T>(maybeNumber: T): MyConditional<T>;
function test2(maybeNumber: any): string | number {
if (typeof maybeNumber === 'number') {
return maybeNumber;
}
return "Not a number";
}
Okay, hope that helps; good luck!
Playground link to code
Solution 2:
Correct answer and current workaround (at the time of writing):
type MaybeNumberType<T extends number | string> = T extends number
? number
: string;
function test<T extends number | string>(
maybeNumber: T,
): MaybeNumberType<T> {
if (typeof maybeNumber === 'number') {
return <MaybeNumberType<T>>(<unknown>maybeNumber);
}
return <MaybeNumberType<T>>(<unknown>'Not a number');
}
test(3); // 3
test('s'); // Not a number
Solution 3:
Your function would be better off if implemented with overloads:
function test(arg: number): number;
function test(arg: unknown): string;
function test(arg: any): string | number {
if (typeof arg === 'number') {
return arg;
}
return 'Not a number'
}