Reputation: 12203
I'm trying a very basic (contrived) conditional type function and getting unexpected errors:
function test<
T
>(
maybeNumber: T
): T extends number ? number : string {
if (typeof maybeNumber === 'number') {
return maybeNumber // Type 'T & number' is not assignable to type 'T extends number ? number : string'.
}
return 'Not a number' // Type '"Not a number"' is not assignable to type 'T extends number ? number : string'.
}
I thought that this was a pretty straightforward usage of a conditional type so not sure what's going on. Any ideas?
To clarify, I’m not really trying to implement this specific function. I’m just experimenting with conditional types and want to better understand why this doesn’t actually work.
Upvotes: 11
Views: 2194
Reputation: 328262
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";
}
Upvotes: 17
Reputation:
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
Upvotes: 3
Reputation: 51043
The problem is that maybeNumber
can be a number at runtime even when T
does not extend number
. In that case, your function returns a number
but its type declaration says it should return string
. Consider:
function test_the_test(value: Object) {
// no type error here, even though test(value) will return a number at runtime
let s: string = test(value);
}
test_the_test(23);
In this case, T = Object
, which does not extend number
- it is a supertype, not a subtype - so the conditional type T extends number ? number : string
resolves to string
. We can test this with a generic type:
type Test<T> = T extends number ? number : string
type TestObject = Test<Object>
// TestObject is string
So, your function test
actually returns a number in at least one case where it claims it will return a string. That means the function is not type-safe, and it's correct that you get a type error.
In fact, there is no sensible, type-safe implementation of your function given these type annotations. test<number>(23)
should return number
, but test<Object>(23)
should return string
; yet both compile to the same Javascript, so there is no way to know at runtime which type the function is expected to return. The only way to satisfy both constraints is to write a function which never returns (e.g. by unconditionally throwing an exception); if you never return a value, the return value can never have the wrong type. So your function needs to be redesigned.
There are two sensible ways in Typescript to write a function which checks that the input is a number. One is to write a user-defined type guard which narrows the type of its argument to number
if it returns true
:
function isNumber(x: any): x is number {
return typeof x === 'number';
}
The other is to write an assertion function which narrows the type of its argument to number
by throwing an error if it's not a number:
function assertNumber(x: any): asserts x is number {
if(typeof x !== 'number') {
throw new TypeError('Not a number');
}
}
Unfortunately, neither of these returns a string when the input is not a number. But either might be adapted for your actual use case.
Upvotes: 0
Reputation: 25790
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'
}
Upvotes: 2
Reputation: 1991
I believe you were defining the return type in the wrong place:
function test<T extends number | String>(maybeNumber: T) {
if (typeof maybeNumber === 'number') {
return maybeNumber
}
return 'Not a number'
}
Upvotes: 0