function callStringFunction(callback: (s: string) => void) {
callback("unknown string inputted by user");
}
function callNumberFunction(callback: (n: number) => void) {
callback(4); // unknown number inputted by user
}
function genericFunction<T extends string | number>(value: T, genericCallback: (newValue: T) => void) {
if (typeof value === "string") {
console.log(value); // T is known to be a string here
callStringFunction(genericCallback); // But this throws an error: (newValue: T) => void not assignable to (s: string) => void
}
else {
console.log(value); // T is known to be a number here
callNumberFunction(genericCallback); // But this throws an error: (newValue: T) => void not assignable to (n: number) => void
}
}
How can I do this without resorting to: callNumberFunction(cb as (n: number) => void)
Since the type for value
has been checked, shouldn't TypeScript be aware of the callback type as well?
CodePudding user response:
Unfortunately the TypeScript compiler is not able to understand what you are doing here. When you check typeof value === "string"
, the compiler treats this as a type guard that narrows value
from T
to either string
or number
. But it does nothing to narrow the type parameter T
itself.
You would like the compiler to see typeof value === "string"
and value: T
together and narrow T
from T extends string | number
to T extends string
or T extends number
or perhaps just specific string
and number
type. This does not happen. T
stays T
throughout the function, and so callStringFunction(genericCallback)
cannot be accepted. The compiler still thinks that T
might be some arbitrary subtype of string | number
.
There are open feature requests in GitHub to improve this situation somehow; see microsoft/TypeScript#33014 for example. Nothing has been implemented yet, though, partly because some "obvious" implementations would lead quickly to unsoundness.
For example, if I had function f<T extends string | number>(value1: T, value2: T): void;
, I couldn't say that value1
being a string
implies that value2
is also a string
. After all, maybe value1
and value2
are both string | number
, as in the call f(Math.random()<0.99 ? "" : 123, 123);
. There'd be a 99% chance of value1
being a string
while value2
is a number
. So any code to deal with this would need to be carefully considered.
Furthermore, I can't think of a good way to refactor your code so that the compiler can see what you're doing as safe. The types string
and number
are not considered discriminant property types, so I can't rewrite the args list to genericFunction()
as a discriminated union. That would only work if you passed in a literal parameter like function genericFunction<K extends "str" | "num">(type: K, val: Val[K], cb: (x: Val[K])=>void): void;
. In such a case you could check type
and then the compiler would narrow val
and cb
together. But this isn't what you have, so it's not a straight refactoring.
For now I'd say that the best way to proceed is to use a type assertion to just tell the compiler that you know what you're doing. It's reasonable to write val as Type
in cases where you are positive that val
really is of type Type
but the compiler cannot:
function genericFunction<T extends string | number>(
value: T, genericCallback: (newValue: T) => void
) {
if (typeof value === "string") {
console.log(value); // T is known to be a string here
callStringFunction(genericCallback as (newValue: string) => void);
}
else {
console.log(value); // T is known to be a number here
callNumberFunction(genericCallback as (newValue: number) => void);
}
}
This fixes the errors, but places the burden of ensuring type safety on you. It's not great, but it's the best I can figure out how to do as of now, without refactoring to an observably different algorithm. Oh well!