Reputation: 1275
What I'm trying to write in plain Javascript would be:
const cleanVar = (var1): T => var1?.replace(searchValue, replaceValue);
In Typescript I would expect to be able to type this as:
const cleanVar = <T extends string | undefined>(var1: T): T => var1?.replace(searchValue, replaceValue);
But I get this error:
Type 'string | undefined' is not assignable to type 'T'.
'string | undefined' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | undefined'.
Type 'undefined' is not assignable to type 'T'.
'undefined' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string | undefined'.
So, what is the correct way to type this function?
The meaning should be: var
could be either string
or undefined
. if var
is string
the return type must be string
. If var
is undefined
the return type must be undefined
.
Upvotes: 7
Views: 7270
Reputation: 327754
It isn't true that cleanVar()
returns T
, since string
literal types exist, and so if you call cleanVar("hello")
, the inferred type of T
will be "hello"
and not string
. Even if you could arrange the call to replace()
so that it was actually an identity function at runtime (e.g., v.replace("a","a")
), all the compiler knows about the return type for String.prototype.replace()
according to the TS standard library is that it's a string
. Since string
contains values not assignable to T
, the compiler correctly complains.
The "right" call signature for cleanVar()
is probably this:
declare const cleanVar: <T extends string | undefined>(v: T) =>
T extends string ? string : undefined;
That is, it should return a conditional type that distributes over unions in T
. Armed with this signature, let's verify that it works the way you want when called:
const x = cleanVar("hello") // const x: string
const y = cleanVar(undefined) // const y: undefined;
const z = cleanVar(Math.random() < 0.5 ? "hello" : undefined) // const z: string | undefined
Looks good!
Unfortunately you will still have a compiler error in your implementation:
const cleanVar = <T extends string | undefined>(v: T):
T extends string ? string : undefined =>
v?.replace(searchValue, replaceValue); // error!
// Type 'string | undefined' is not assignable to type 'T extends string ? string : undefined'.
And that's because the compiler cannot really verify that any specific value is assignable to a conditional type which depends on an unresolved generic type parameter, such as T
within the body of cleanVar
's implementation. It defers evaluation of such conditional types, which remain "opaque". This is a known pain point in TypeScript, and there's an open issue at microsoft/TypeScript#33912 asking for some solution whereby the type parameter T
can be narrowed based on control flow analysis. Right now v?.xyz
will narrow v
from T
to string
inside xyz
, but nothing you do in the function can narrow T
itself from T extends string | undefined
to T extends string
. And we're stuck.
For now the only way to do this is with a type assertion; where the responsibility for verifying the correctness of your implementation is shifted from the compiler (which can't do it) to you... so be careful. Anyway, it looks like this:
const cleanVar = <T extends string | undefined>(v: T) =>
v?.replace(searchValue, replaceValue) as T extends string ? string : undefined;
Another "right" way to proceed would be to make cleanVar()
an overloaded function with multiple call signatures, like this:
function cleanVar(v: string): string;
function cleanVar(v: undefined): undefined;
function cleanVar(v: string | undefined): string | undefined;
function cleanVar(v: string | undefined) {
return v?.replace(searchValue, replaceValue);
}
This works similarly to the generic-conditional version, but is wordier. You can't easily make this an arrow function without type assertions (defeating the purpose), and even with function statements as above, the lack of error in the implementation is similar to using a type assertion... the compiler only cares that the implementation signature is matched. If you changed it to
function cleanVarBad(v: string): string;
function cleanVarBad(v: undefined): undefined;
function cleanVarBad(v: string | undefined): string | undefined;
function cleanVarBad(v: string | undefined): string | undefined {
return Math.random() < 0.5 ? "oopsie" : undefined;
}
then you'd still not get an error, but you'd probably be very unhappy at runtime. So there really isn't a major benefit to one over the other; I'd generally prefer generic-conditional call signatures to overloads.
Upvotes: 11