heapOverflow
heapOverflow

Reputation: 1275

How to create a typescript function returning a string or undefined based on the input being a string or undefined?

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 11

Related Questions